authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 21:48:41-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
logad9bc6c1df40ac41ee9f50405bb17bbea6b08674
tree474a45d5bc744168b7aee4c5f9c800c85cbfd535
parent76519303b9a488a0b9f4fb4c4b1968d6f9bacba8

Improve chat layout responsiveness and adopt new sidebar primitives

- 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 tweaks

11 files changed, 1128 insertions(+), 230 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+1-1
......@@ -16,7 +16,7 @@ const markdownComponents: Components = {
1616
1717function ChatMarkdown({ text }: ChatMarkdownProps) {
1818 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">
2020 <ReactMarkdown
2121 remarkPlugins={[remarkGfm]}
2222 rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
apps/web/src/components/ChatView.tsx+45-25
......@@ -112,6 +112,7 @@ import {
112112 setupProjectScript,
113113} from "~/projectScripts";
114114import { Toggle } from "./ui/toggle";
115import { SidebarTrigger } from "./ui/sidebar";
115116import {
116117 asApprovalRequestId,
117118 asProjectId,
......@@ -1666,6 +1667,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
16661667 if (!activeThread) {
16671668 return (
16681669 <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 )}
16691678 {isElectron && (
16701679 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
16711680 <span className="text-xs text-muted-foreground/50">No active thread</span>
......@@ -1681,10 +1690,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
16811690 }
16821691
16831692 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">
16851694 {/* Top bar */}
16861695 <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 )}
16881700 >
16891701 <ChatHeader
16901702 activeThreadId={activeThread.id}
......@@ -1718,7 +1730,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17181730 {/* Messages */}
17191731 <div
17201732 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"
17221734 onScroll={onMessagesScroll}
17231735 >
17241736 <MessagesTimeline
......@@ -1741,8 +1753,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
17411753 </div>
17421754
17431755 {/* Input bar */}
1744 <div className={cn("px-5 pt-2", isGitRepo ? "pb-1" : "pb-4")}>
1745 <form onSubmit={onSend} className="mx-auto max-w-3xl">
1756 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>
1757 <form onSubmit={onSend} className="mx-auto w-full max-w-3xl">
17461758 <div
17471759 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
17481760 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"
......@@ -1753,7 +1765,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17531765 onDrop={onComposerDrop}
17541766 >
17551767 {/* 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">
17571769 {composerMenuOpen && (
17581770 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
17591771 <ComposerCommandMenu
......@@ -1840,24 +1852,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
18401852 </div>
18411853
18421854 {/* Bottom toolbar */}
1843 <div className="flex items-center justify-between px-3 pb-3">
1844 <div className="flex items-center gap-1">
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">
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">
18451857 {/* Model picker */}
18461858 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />
18471859
18481860 {/* Divider */}
1849 <Separator orientation="vertical" className="mx-0.5 h-4" />
1861 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
18501862
18511863 {/* Reasoning effort */}
18521864 <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} />
18531865
18541866 {/* Divider */}
1855 <Separator orientation="vertical" className="mx-0.5 h-4" />
1867 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
18561868
18571869 {/* Runtime mode toggle */}
18581870 <Button
18591871 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"
18611873 size="sm"
18621874 type="button"
18631875 disabled={isSwitchingRuntimeMode}
......@@ -1873,16 +1885,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
18731885 }
18741886 >
18751887 {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>
18771891 </Button>
18781892 </div>
18791893
18801894 {/* Right side: send / stop button */}
1881 <div className="flex items-center gap-2">
1895 <div className="flex shrink-0 items-center gap-2">
18821896 {phase === "running" ? (
18831897 <button
18841898 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"
18861900 onClick={() => void onInterrupt()}
18871901 aria-label="Stop generation"
18881902 >
......@@ -1899,7 +1913,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18991913 ) : (
19001914 <button
19011915 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"
19031917 disabled={
19041918 isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
19051919 }
......@@ -2061,14 +2075,19 @@ const ChatHeader = memo(function ChatHeader({
20612075 onToggleDiff,
20622076}: ChatHeaderProps) {
20632077 return (
2064 <>
2065 <div className="flex min-w-0 flex-1 items-center gap-3">
2078 <div className="flex min-w-0 flex-1 items-center justify-between gap-2">
2079 <div className="flex min-w-0 items-center gap-2 sm:gap-3">
2080 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
20662081 <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}>
20672082 {activeThreadTitle}
20682083 </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 )}
20702089 </div>
2071 <div className="shrink-0 flex items-center gap-3">
2090 <div className="flex shrink-0 items-center gap-2 sm:gap-3">
20722091 {activeProjectScripts && (
20732092 <ProjectScriptsControl
20742093 scripts={activeProjectScripts}
......@@ -2086,6 +2105,7 @@ const ChatHeader = memo(function ChatHeader({
20862105 <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} />
20872106 )}
20882107 <Toggle
2108 className="shrink-0"
20892109 pressed={diffOpen}
20902110 onPressedChange={onToggleDiff}
20912111 aria-label="Toggle diff panel"
......@@ -2095,7 +2115,7 @@ const ChatHeader = memo(function ChatHeader({
20952115 <DiffIcon className="size-3" />
20962116 </Toggle>
20972117 </div>
2098 </>
2118 </div>
20992119 );
21002120});
21012121
......@@ -2309,7 +2329,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
23092329
23102330 return (
23112331 <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"
23132333 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
23142334 >
23152335 {virtualRows.map((virtualRow: VirtualItem) => {
......@@ -2428,7 +2448,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
24282448 </div>
24292449 )}
24302450 {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">
24322452 {row.message.text}
24332453 </pre>
24342454 )}
......@@ -2469,7 +2489,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
24692489 <span className="h-px flex-1 bg-border" />
24702490 </div>
24712491 )}
2472 <div className="px-1 py-0.5">
2492 <div className="min-w-0 px-1 py-0.5">
24732493 <ChatMarkdown text={messageText} />
24742494 {(() => {
24752495 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
......@@ -2708,9 +2728,9 @@ const OpenInPicker = memo(function OpenInPicker({
27082728 <Group aria-label="Subscription actions">
27092729 <Button size="xs" variant="outline" onClick={() => openInEditor(lastEditor)}>
27102730 {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>
27122732 </Button>
2713 <GroupSeparator />
2733 <GroupSeparator className="hidden sm:block" />
27142734 <Menu>
27152735 <MenuTrigger render={<Button aria-label="Copy options" size="icon-xs" variant="outline" />}>
27162736 <ChevronDownIcon aria-hidden="true" className="size-4" />
apps/web/src/components/GitActionsControl.tsx+3-3
......@@ -501,7 +501,7 @@ export default function GitActionsControl({
501501 }
502502 >
503503 <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>
505505 </PopoverTrigger>
506506 <PopoverPopup tooltipStyle side="bottom" align="start">
507507 {quickActionDisabledReason}
......@@ -515,10 +515,10 @@ export default function GitActionsControl({
515515 onClick={runQuickAction}
516516 >
517517 <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>
519519 </Button>
520520 )}
521 <GroupSeparator />
521 <GroupSeparator className="hidden sm:block" />
522522 <Menu
523523 onOpenChange={(open) => {
524524 if (open) void invalidateGitQueries(queryClient);
apps/web/src/components/ProjectScriptsControl.tsx+4-4
......@@ -266,9 +266,9 @@ export default function ProjectScriptsControl({
266266 title={`Run ${primaryScript.name}`}
267267 >
268268 <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>
270270 </Button>
271 <GroupSeparator />
271 <GroupSeparator className="hidden sm:block" />
272272 <Menu highlightItemOnHover={false}>
273273 <MenuTrigger
274274 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
......@@ -327,9 +327,9 @@ export default function ProjectScriptsControl({
327327 </Menu>
328328 </Group>
329329 ) : (
330 <Button size="xs" variant="outline" onClick={openAddDialog}>
330 <Button size="xs" variant="outline" onClick={openAddDialog} title="Add action">
331331 <PlusIcon className="size-3.5" />
332 Add action
332 <span className="sr-only sm:not-sr-only sm:ml-0.5">Add action</span>
333333 </Button>
334334 )}
335335
apps/web/src/components/Sidebar.tsx+230-189
......@@ -1,4 +1,4 @@
1import { TerminalIcon } from "lucide-react";
1import { ChevronRightIcon, TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
33import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
44import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
......@@ -15,6 +15,22 @@ import { useNativeApi } from "../hooks/useNativeApi";
1515import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
1616import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1717import { toastManager } from "./ui/toast";
18import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
19import {
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";
1834import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1935
2036const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
......@@ -431,23 +447,22 @@ export default function Sidebar() {
431447 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
432448
433449 return (
434 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
435 {/* Branding */}
436 <div
437 className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`}
450 <>
451 <SidebarHeader
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"}`}
438453 >
439 <div className="flex min-w-0 flex-1 items-center gap-2">
440 <span className="truncate text-sm font-semibold tracking-tight text-foreground">
441 T3 <span className="font-normal text-muted-foreground">Code</span>
442 </span>
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">
444 {APP_STAGE_LABEL}
445 </span>
454 <div className="flex items-center gap-2">
455 <SidebarTrigger className="shrink-0 md:hidden" />
456 <div className="flex min-w-0 flex-1 items-center gap-2">
457 <span className="truncate text-sm font-semibold tracking-tight text-foreground">
458 T3 <span className="font-normal text-muted-foreground">Code</span>
459 </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>
446464 </div>
447 </div>
448465
449 {/* New thread (global) */}
450 <div className="px-3 pb-3">
451466 <button
452467 type="button"
453468 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() {
463478 <span className="text-foreground">+</span>
464479 New thread
465480 </button>
466 </div>
467
468 {/* Project list */}
469 <nav className="flex-1 overflow-y-auto px-2">
470 {state.projects.map((project) => {
471 const threads = state.threads
472 .filter((t) => t.projectId === project.id)
473 .toSorted((a, b) => {
474 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
475 if (byDate !== 0) return byDate;
476 return b.id.localeCompare(a.id);
477 });
478 return (
479 <div key={project.id} className="mb-1">
480 {/* Project header */}
481 </SidebarHeader>
482
483 <SidebarContent className="gap-0">
484 <SidebarGroup className="px-2 py-2">
485 <SidebarMenu>
486 {state.projects.map((project) => {
487 const threads = state.threads
488 .filter((thread) => thread.projectId === project.id)
489 .toSorted((a, b) => {
490 const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
491 if (byDate !== 0) return byDate;
492 return b.id.localeCompare(a.id);
493 });
494
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 && (
481653 <button
482654 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"
484 onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })}
485 onContextMenu={(e) => {
486 e.preventDefault();
487 void handleProjectContextMenu(project.id, {
488 x: e.clientX,
489 y: e.clientY,
490 });
491 }}
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"
656 onClick={() => void handlePickFolder()}
657 disabled={isPickingFolder || isAddingProject}
492658 >
493 <span className="text-[10px] text-muted-foreground/70">
494 {project.expanded ? "â–¼" : "â–¶"}
495 </span>
496 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
497 {project.name}
498 </span>
499 <span className="text-[10px] text-muted-foreground/60">{threads.length}</span>
659 {isPickingFolder ? "Picking folder..." : "Browse for folder"}
660 </button>
661 )}
662 <div className="flex gap-2">
663 <button
664 type="button"
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
500677 </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 )}
583678 </div>
584 );
585 })}
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">
679 </>
680 ) : (
642681 <button
643682 type="button"
644683 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() {
646685 >
647686 + Add project
648687 </button>
649 </div>
650 )}
651 </aside>
688 )}
689 </SidebarFooter>
690
691 <SidebarRail />
692 </>
652693 );
653694}
apps/web/src/components/ui/collapsible.tsx created+45
......@@ -0,0 +1,45 @@
1"use client";
2
3import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
4
5import { cn } from "~/lib/utils";
6
7function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
8 return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
9}
10
11function 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
24function 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
40export {
41 Collapsible,
42 CollapsibleTrigger,
43 CollapsiblePanel,
44 CollapsiblePanel as CollapsibleContent,
45};
apps/web/src/components/ui/sidebar.tsx created+700
......@@ -0,0 +1,700 @@
1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { cva, type VariantProps } from "class-variance-authority";
4import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react";
5import * as React from "react";
6import { cn } from "~/lib/utils";
7import { Button } from "~/components/ui/button";
8import { Input } from "~/components/ui/input";
9import { ScrollArea } from "~/components/ui/scroll-area";
10import { Separator } from "~/components/ui/separator";
11import {
12 Sheet,
13 SheetDescription,
14 SheetHeader,
15 SheetPopup,
16 SheetTitle,
17} from "~/components/ui/sheet";
18import { Skeleton } from "~/components/ui/skeleton";
19import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
20import { useMediaQuery } from "~/hooks/useMediaQuery";
21
22const SIDEBAR_COOKIE_NAME = "sidebar_state";
23const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
24const SIDEBAR_WIDTH = "16rem";
25const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))";
26const SIDEBAR_WIDTH_ICON = "3rem";
27const SIDEBAR_KEYBOARD_SHORTCUT = "b";
28
29type 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
39const SidebarContext = React.createContext<SidebarContextProps | null>(null);
40
41function 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
50function 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
148function 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
254function 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
276function 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
302function 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
316function 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
327function 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
338function 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
349function 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
360function 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
376function 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
387function 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
405function 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
425function 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
436function 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
447function 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
458const 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
480function 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
533function 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
565function 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
584function 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
617function 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
632function 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
643function 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
675export {
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 @@
1import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
2
3import { cn } from "~/lib/utils";
4
5const TooltipCreateHandle = TooltipPrimitive.createHandle;
6
7const TooltipProvider = TooltipPrimitive.Provider;
8
9const Tooltip = TooltipPrimitive.Root;
10
11function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
12 return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
13}
14
15function 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
59export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
apps/web/src/index.css+15
......@@ -133,6 +133,18 @@ body {
133133 min-height: 100vh;
134134}
135135
136html,
137body,
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
136148body::after {
137149 content: "";
138150 position: fixed;
......@@ -229,7 +241,9 @@ label:has(> select#reasoning-effort) select {
229241
230242/* Chat markdown rendering */
231243.chat-markdown {
244 min-width: 0;
232245 overflow-wrap: anywhere;
246 word-break: break-word;
233247}
234248
235249.chat-markdown > :first-child {
......@@ -283,6 +297,7 @@ label:has(> select#reasoning-effort) select {
283297}
284298
285299.chat-markdown pre {
300 max-width: 100%;
286301 overflow-x: auto;
287302 border: 1px solid var(--border);
288303 border-radius: 0.75rem;
apps/web/src/routes/_chat.index.tsx+10
......@@ -1,10 +1,20 @@
11import { createFileRoute } from "@tanstack/react-router";
22
33import { isElectron } from "../env";
4import { SidebarTrigger } from "../components/ui/sidebar";
45
56function ChatIndexRouteView() {
67 return (
78 <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
818 {isElectron && (
919 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
1020 <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";
22import { useEffect } from "react";
33
44import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
5import Sidebar from "../components/Sidebar";
5import ThreadSidebar from "../components/Sidebar";
6import { SidebarInset, Sidebar, SidebarProvider } from "~/components/ui/sidebar";
67
78function ChatRouteLayout() {
89 const navigate = useNavigate();
......@@ -24,16 +25,23 @@ function ChatRouteLayout() {
2425 }, [navigate]);
2526
2627 return (
27 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
28 <Sidebar />
29 <DiffWorkerPoolProvider>
30 <Outlet />
31 </DiffWorkerPoolProvider>
32 </div>
28 <SidebarProvider defaultOpen>
29 <Sidebar
30 side="left"
31 collapsible="offcanvas"
32 className="border-r border-border bg-card text-foreground"
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>
3342 );
3443}
3544
3645export const Route = createFileRoute("/_chat")({
3746 component: ChatRouteLayout,
3847});
39