@castlekit/castle 0.1.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/drizzle.config.ts +7 -0
  3. package/install.sh +20 -1
  4. package/next.config.ts +1 -0
  5. package/package.json +35 -3
  6. package/src/app/api/avatars/[id]/route.ts +57 -7
  7. package/src/app/api/openclaw/agents/route.ts +7 -1
  8. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  9. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  10. package/src/app/api/openclaw/chat/channels/route.ts +217 -0
  11. package/src/app/api/openclaw/chat/route.ts +283 -0
  12. package/src/app/api/openclaw/chat/search/route.ts +150 -0
  13. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  14. package/src/app/api/openclaw/config/route.ts +2 -0
  15. package/src/app/api/openclaw/events/route.ts +23 -8
  16. package/src/app/api/openclaw/logs/route.ts +17 -3
  17. package/src/app/api/openclaw/ping/route.ts +5 -0
  18. package/src/app/api/openclaw/restart/route.ts +6 -1
  19. package/src/app/api/openclaw/session/context/route.ts +163 -0
  20. package/src/app/api/openclaw/session/status/route.ts +210 -0
  21. package/src/app/api/openclaw/sessions/route.ts +2 -0
  22. package/src/app/api/settings/avatar/route.ts +190 -0
  23. package/src/app/api/settings/route.ts +88 -0
  24. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  25. package/src/app/chat/[channelId]/page.tsx +385 -0
  26. package/src/app/chat/layout.tsx +96 -0
  27. package/src/app/chat/page.tsx +52 -0
  28. package/src/app/globals.css +99 -2
  29. package/src/app/layout.tsx +7 -1
  30. package/src/app/page.tsx +59 -25
  31. package/src/app/settings/page.tsx +300 -0
  32. package/src/components/chat/agent-mention-popup.tsx +89 -0
  33. package/src/components/chat/archived-channels.tsx +190 -0
  34. package/src/components/chat/channel-list.tsx +140 -0
  35. package/src/components/chat/chat-input.tsx +328 -0
  36. package/src/components/chat/create-channel-dialog.tsx +171 -0
  37. package/src/components/chat/markdown-content.tsx +205 -0
  38. package/src/components/chat/message-bubble.tsx +168 -0
  39. package/src/components/chat/message-list.tsx +666 -0
  40. package/src/components/chat/message-queue.tsx +68 -0
  41. package/src/components/chat/session-divider.tsx +61 -0
  42. package/src/components/chat/session-stats-panel.tsx +444 -0
  43. package/src/components/chat/storage-indicator.tsx +76 -0
  44. package/src/components/layout/sidebar.tsx +126 -45
  45. package/src/components/layout/user-menu.tsx +29 -4
  46. package/src/components/providers/presence-provider.tsx +8 -0
  47. package/src/components/providers/search-provider.tsx +110 -0
  48. package/src/components/search/search-dialog.tsx +269 -0
  49. package/src/components/ui/avatar.tsx +11 -9
  50. package/src/components/ui/dialog.tsx +10 -4
  51. package/src/components/ui/tooltip.tsx +25 -8
  52. package/src/components/ui/twemoji-text.tsx +37 -0
  53. package/src/lib/api-security.ts +125 -0
  54. package/src/lib/date-utils.ts +79 -0
  55. package/src/lib/db/index.ts +652 -0
  56. package/src/lib/db/queries.ts +1144 -0
  57. package/src/lib/db/schema.ts +164 -0
  58. package/src/lib/gateway-connection.ts +24 -3
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +753 -0
  61. package/src/lib/hooks/use-compaction-events.ts +132 -0
  62. package/src/lib/hooks/use-context-boundary.ts +82 -0
  63. package/src/lib/hooks/use-openclaw.ts +122 -100
  64. package/src/lib/hooks/use-search.ts +114 -0
  65. package/src/lib/hooks/use-session-stats.ts +60 -0
  66. package/src/lib/hooks/use-user-settings.ts +46 -0
  67. package/src/lib/sse-singleton.ts +184 -0
  68. package/src/lib/types/chat.ts +202 -0
  69. package/src/lib/types/search.ts +60 -0
  70. package/src/middleware.ts +52 -0
@@ -1,15 +1,25 @@
1
1
  "use client";
2
2
 
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { useTheme } from "next-themes";
3
5
  import {
4
6
  LayoutDashboard,
7
+ MessageCircle,
8
+ User,
9
+ Sun,
10
+ Moon,
11
+ Settings,
12
+ Loader2,
5
13
  type LucideIcon,
6
14
  } from "lucide-react";
7
- import { Fragment } from "react";
8
15
  import { CastleIcon } from "@/components/icons/castle-icon";
9
16
  import { Tooltip } from "@/components/ui/tooltip";
10
17
  import { cn } from "@/lib/utils";
11
- import Link from "next/link";
12
- import { usePathname } from "next/navigation";
18
+ import { usePathname, useRouter } from "next/navigation";
19
+ import { useUserSettings } from "@/lib/hooks/use-user-settings";
20
+ // Search is now a floating bar (top-right), no longer in sidebar
21
+ import { useAgentStatus, USER_STATUS_ID } from "@/lib/hooks/use-agent-status";
22
+ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
13
23
 
14
24
  interface NavItem {
15
25
  id: string;
@@ -32,6 +42,12 @@ const navItems: NavItem[] = [
32
42
  icon: LayoutDashboard,
33
43
  href: "/",
34
44
  },
45
+ {
46
+ id: "chat",
47
+ label: "Chat",
48
+ icon: MessageCircle,
49
+ href: "/chat",
50
+ },
35
51
  ];
36
52
 
37
53
  function Sidebar({
@@ -41,11 +57,13 @@ function Sidebar({
41
57
  variant = "solid"
42
58
  }: SidebarProps) {
43
59
  const pathname = usePathname();
60
+ const router = useRouter();
44
61
  const useLinks = !onNavigate;
45
-
62
+ const { tooltips: showTooltips } = useUserSettings();
46
63
  const activeFromPath = (() => {
47
64
  if (!pathname) return "dashboard";
48
65
  if (pathname === "/") return "dashboard";
66
+ if (pathname.startsWith("/chat")) return "chat";
49
67
  return "dashboard";
50
68
  })();
51
69
 
@@ -54,54 +72,32 @@ function Sidebar({
54
72
  return (
55
73
  <aside
56
74
  className={cn(
57
- "fixed top-5 left-6 bottom-5 flex flex-col z-40 shadow-xl shadow-black/20 rounded-[28px] w-14",
75
+ "fixed top-[20px] left-[24px] bottom-[20px] flex flex-col z-40 rounded-[var(--radius-md)] w-14",
58
76
  variant === "glass" ? "glass" : "bg-surface border border-border",
59
77
  className
60
78
  )}
61
79
  >
62
80
  {/* Header */}
63
81
  <div className="flex items-center justify-center pt-5 pb-[60px]">
64
- {useLinks ? (
65
- <Link
66
- href="/"
67
- aria-label="Go to Dashboard"
68
- className="flex items-center justify-center transition-opacity hover:opacity-85"
69
- >
70
- <CastleIcon className="h-[36px] w-[36px] min-h-[36px] min-w-[36px] shrink-0 text-[var(--logo-color)] -mt-[3px]" />
71
- </Link>
72
- ) : (
73
- <button
74
- type="button"
75
- aria-label="Go to Dashboard"
76
- onClick={() => onNavigate?.("dashboard")}
77
- className="flex items-center justify-center transition-opacity hover:opacity-85 cursor-pointer"
78
- >
79
- <CastleIcon className="h-[36px] w-[36px] min-h-[36px] min-w-[36px] shrink-0 text-[var(--logo-color)] -mt-[3px]" />
80
- </button>
81
- )}
82
+ <button
83
+ type="button"
84
+ aria-label="Go to Dashboard"
85
+ onClick={() => useLinks ? router.push("/") : onNavigate?.("dashboard")}
86
+ className="flex items-center justify-center transition-opacity hover:opacity-85 cursor-pointer"
87
+ >
88
+ <CastleIcon className="h-[36px] w-[36px] min-h-[36px] min-w-[36px] shrink-0 text-[var(--logo-color)] -mt-[3px]" />
89
+ </button>
82
90
  </div>
83
91
 
84
92
  {/* Navigation */}
85
93
  <nav className="flex-1 space-y-1 px-2">
86
94
  {navItems.map((item) => {
87
95
  const isActive = effectiveActive === item.id;
88
- const NavEl = useLinks ? (
89
- <Link
90
- href={item.href}
91
- className={cn(
92
- "flex items-center justify-center w-full rounded-[20px] p-2.5 cursor-pointer",
93
- isActive
94
- ? "bg-accent/10 text-accent"
95
- : "text-foreground-secondary hover:text-foreground hover:bg-surface-hover"
96
- )}
97
- >
98
- <item.icon className="h-5 w-5 shrink-0" />
99
- </Link>
100
- ) : (
96
+ const NavEl = (
101
97
  <button
102
- onClick={() => onNavigate?.(item.id)}
98
+ onClick={() => useLinks ? router.push(item.href) : onNavigate?.(item.id)}
103
99
  className={cn(
104
- "flex items-center justify-center w-full rounded-[20px] p-2.5 cursor-pointer",
100
+ "flex items-center justify-center w-full rounded-[4px] p-2.5 cursor-pointer",
105
101
  isActive
106
102
  ? "bg-accent/10 text-accent"
107
103
  : "text-foreground-secondary hover:text-foreground hover:bg-surface-hover"
@@ -111,18 +107,103 @@ function Sidebar({
111
107
  </button>
112
108
  );
113
109
 
114
- return (
115
- <Tooltip key={item.id} content={item.label} side="right">
116
- {NavEl}
117
- </Tooltip>
118
- );
110
+ if (showTooltips) {
111
+ return (
112
+ <Tooltip key={item.id} content={item.label} side="right">
113
+ {NavEl}
114
+ </Tooltip>
115
+ );
116
+ }
117
+ return <div key={item.id}>{NavEl}</div>;
119
118
  })}
120
119
  </nav>
121
120
 
122
- {/* Spacer at bottom for visual balance */}
123
- <div className="pb-4" />
121
+ {/* User menu at bottom */}
122
+ <SidebarUserMenu />
124
123
  </aside>
125
124
  );
126
125
  }
127
126
 
127
+ function SidebarUserMenu() {
128
+ const [open, setOpen] = useState(false);
129
+ const { theme, setTheme } = useTheme();
130
+ const [mounted, setMounted] = useState(false);
131
+ const router = useRouter();
132
+ const { avatarUrl, isLoading: settingsLoading } = useUserSettings();
133
+ const { getStatus } = useAgentStatus();
134
+ const userStatus = getStatus(USER_STATUS_ID);
135
+ const avatarDotStatus = userStatus === "active" ? "online" as const : "offline" as const;
136
+ const menuRef = useRef<HTMLDivElement>(null);
137
+
138
+ useEffect(() => {
139
+ setMounted(true);
140
+ }, []);
141
+
142
+ useEffect(() => {
143
+ function handleClickOutside(event: MouseEvent) {
144
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
145
+ setOpen(false);
146
+ }
147
+ }
148
+ document.addEventListener("mousedown", handleClickOutside);
149
+ return () => document.removeEventListener("mousedown", handleClickOutside);
150
+ }, []);
151
+
152
+ const isDark = theme === "dark";
153
+
154
+ return (
155
+ <div ref={menuRef} className="relative flex justify-center pb-[8px]">
156
+ <button
157
+ onClick={() => setOpen(!open)}
158
+ className="group flex items-center justify-center rounded-[4px] cursor-pointer transition-opacity"
159
+ >
160
+ <Avatar size="sm" status={avatarDotStatus}>
161
+ {settingsLoading ? (
162
+ <AvatarFallback className="skeleton" />
163
+ ) : avatarUrl ? (
164
+ <AvatarImage
165
+ src={avatarUrl}
166
+ alt="You"
167
+ className="grayscale group-hover:grayscale-0 transition-all duration-200"
168
+ />
169
+ ) : (
170
+ <AvatarFallback className="text-foreground-secondary">
171
+ <User className="h-5 w-5" />
172
+ </AvatarFallback>
173
+ )}
174
+ </Avatar>
175
+ </button>
176
+
177
+ {open && (
178
+ <div className="absolute left-[calc(100%+8px)] bottom-0 w-48 rounded-[var(--radius-md)] bg-surface border border-border shadow-xl py-1 z-50">
179
+ <button
180
+ onClick={() => { setOpen(false); router.push("/settings"); }}
181
+ className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-foreground-secondary hover:text-foreground hover:bg-surface-hover cursor-pointer"
182
+ >
183
+ <Settings className="h-4 w-4" />
184
+ Settings
185
+ </button>
186
+
187
+ {mounted && (
188
+ <button
189
+ onClick={() => {
190
+ setTheme(isDark ? "light" : "dark");
191
+ setOpen(false);
192
+ }}
193
+ className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-foreground-secondary hover:text-foreground hover:bg-surface-hover cursor-pointer"
194
+ >
195
+ {isDark ? (
196
+ <Sun className="h-4 w-4" />
197
+ ) : (
198
+ <Moon className="h-4 w-4" />
199
+ )}
200
+ {isDark ? "Light mode" : "Dark mode"}
201
+ </button>
202
+ )}
203
+ </div>
204
+ )}
205
+ </div>
206
+ );
207
+ }
208
+
128
209
  export { Sidebar };
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { useState, useRef, useEffect } from "react";
4
4
  import { useTheme } from "next-themes";
5
- import { User, Sun, Moon } from "lucide-react";
5
+ import { useRouter } from "next/navigation";
6
+ import { User, Sun, Moon, Settings } from "lucide-react";
6
7
  import { cn } from "@/lib/utils";
8
+ import { useUserSettings } from "@/lib/hooks/use-user-settings";
7
9
 
8
10
  export interface UserMenuProps {
9
11
  className?: string;
@@ -14,6 +16,8 @@ function UserMenu({ className, variant = "solid" }: UserMenuProps) {
14
16
  const [open, setOpen] = useState(false);
15
17
  const { theme, setTheme } = useTheme();
16
18
  const [mounted, setMounted] = useState(false);
19
+ const router = useRouter();
20
+ const { avatarUrl } = useUserSettings();
17
21
  const menuRef = useRef<HTMLDivElement>(null);
18
22
 
19
23
  useEffect(() => {
@@ -37,15 +41,36 @@ function UserMenu({ className, variant = "solid" }: UserMenuProps) {
37
41
  <button
38
42
  onClick={() => setOpen(!open)}
39
43
  className={cn(
40
- "flex items-center justify-center h-14 w-14 rounded-[28px] shadow-xl shadow-black/20 text-foreground-secondary hover:text-foreground cursor-pointer",
41
- variant === "glass" ? "glass" : "bg-surface border border-border"
44
+ "flex items-center justify-center h-14 w-14 rounded-[28px] shadow-xl shadow-black/20 cursor-pointer overflow-hidden",
45
+ avatarUrl
46
+ ? ""
47
+ : cn(
48
+ "text-foreground-secondary hover:text-foreground",
49
+ variant === "glass" ? "glass" : "bg-surface border border-border"
50
+ )
42
51
  )}
43
52
  >
44
- <User className="h-5 w-5" />
53
+ {avatarUrl ? (
54
+ <img
55
+ src={avatarUrl}
56
+ alt="You"
57
+ className="w-full h-full object-cover"
58
+ />
59
+ ) : (
60
+ <User className="h-5 w-5" />
61
+ )}
45
62
  </button>
46
63
 
47
64
  {open && (
48
65
  <div className="absolute right-0 top-[calc(100%+8px)] w-48 rounded-[var(--radius-md)] bg-surface border border-border shadow-xl py-1 z-50">
66
+ <button
67
+ onClick={() => { setOpen(false); router.push("/settings"); }}
68
+ className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-foreground-secondary hover:text-foreground hover:bg-surface-hover cursor-pointer"
69
+ >
70
+ <Settings className="h-4 w-4" />
71
+ Settings
72
+ </button>
73
+
49
74
  {mounted && (
50
75
  <button
51
76
  onClick={() => {
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ import { useUserPresence } from "@/lib/hooks/use-agent-status";
4
+
5
+ export function PresenceProvider({ children }: { children: React.ReactNode }) {
6
+ useUserPresence();
7
+ return <>{children}</>;
8
+ }
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useState, useEffect, useCallback } from "react";
4
+ import { usePathname } from "next/navigation";
5
+ import { Search } from "lucide-react";
6
+ import { SearchDialog } from "@/components/search/search-dialog";
7
+
8
+ // ============================================================================
9
+ // Context
10
+ // ============================================================================
11
+
12
+ interface SearchContextValue {
13
+ isSearchOpen: boolean;
14
+ openSearch: () => void;
15
+ closeSearch: () => void;
16
+ }
17
+
18
+ const SearchContext = createContext<SearchContextValue>({
19
+ isSearchOpen: false,
20
+ openSearch: () => {},
21
+ closeSearch: () => {},
22
+ });
23
+
24
+ export function useSearchContext() {
25
+ return useContext(SearchContext);
26
+ }
27
+
28
+ // ============================================================================
29
+ // Reusable search trigger button (flow element — no fixed positioning)
30
+ // ============================================================================
31
+
32
+ export function SearchTrigger({ className }: { className?: string }) {
33
+ const { openSearch } = useSearchContext();
34
+ const [isMac, setIsMac] = useState(true);
35
+
36
+ useEffect(() => {
37
+ setIsMac(navigator.platform?.toUpperCase().includes("MAC") ?? true);
38
+ }, []);
39
+
40
+ return (
41
+ <button
42
+ onClick={openSearch}
43
+ className={className ?? "flex items-center gap-3 pl-3 pr-2.5 h-[38px] w-[320px] rounded-[var(--radius-sm)] bg-surface border border-border hover:border-border-hover text-foreground-secondary hover:text-foreground transition-colors cursor-pointer shadow-sm"}
44
+ >
45
+ <Search className="h-4 w-4 shrink-0" strokeWidth={2.5} />
46
+ <span className="text-sm text-foreground-secondary/50 flex-1 text-left">Search Castle...</span>
47
+ <kbd className="flex items-center justify-center h-[22px] px-1.5 gap-1 rounded-[4px] bg-surface-hover border border-border font-medium text-foreground-secondary">
48
+ {isMac ? <span className="text-[15px]">⌘</span> : <span className="text-[11px]">Ctrl</span>}
49
+ <span className="text-[11px]">K</span>
50
+ </kbd>
51
+ </button>
52
+ );
53
+ }
54
+
55
+ // ============================================================================
56
+ // Provider
57
+ // ============================================================================
58
+
59
+ export function SearchProvider({ children }: { children: React.ReactNode }) {
60
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
61
+ const [isMac, setIsMac] = useState(true);
62
+ const pathname = usePathname();
63
+
64
+ // Detect platform for keyboard shortcut display
65
+ useEffect(() => {
66
+ setIsMac(navigator.platform?.toUpperCase().includes("MAC") ?? true);
67
+ }, []);
68
+
69
+ // Hide floating trigger on pages that render their own SearchTrigger in the header
70
+ const showFloatingSearch =
71
+ !["/settings", "/ui-kit", "/"].includes(pathname) &&
72
+ !pathname.startsWith("/chat");
73
+
74
+ const openSearch = useCallback(() => setIsSearchOpen(true), []);
75
+ const closeSearch = useCallback(() => setIsSearchOpen(false), []);
76
+
77
+ // Global shortcut: Cmd+K / Ctrl+K to open search
78
+ useEffect(() => {
79
+ const handler = (e: KeyboardEvent) => {
80
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
81
+ e.preventDefault();
82
+ setIsSearchOpen((prev) => !prev);
83
+ }
84
+ };
85
+
86
+ document.addEventListener("keydown", handler);
87
+ return () => document.removeEventListener("keydown", handler);
88
+ }, []);
89
+
90
+ return (
91
+ <SearchContext.Provider value={{ isSearchOpen, openSearch, closeSearch }}>
92
+ {children}
93
+ {/* Floating search trigger — only on pages without an embedded header trigger */}
94
+ {showFloatingSearch && (
95
+ <button
96
+ onClick={openSearch}
97
+ className="fixed top-[28px] right-[28px] z-40 flex items-center gap-3 pl-3 pr-2.5 h-[38px] w-[320px] rounded-[var(--radius-sm)] bg-surface border border-border hover:border-border-hover text-foreground-secondary hover:text-foreground transition-colors cursor-pointer shadow-sm"
98
+ >
99
+ <Search className="h-4 w-4 shrink-0" strokeWidth={2.5} />
100
+ <span className="text-sm text-foreground-secondary/50 flex-1 text-left">Search Castle...</span>
101
+ <kbd className="flex items-center justify-center h-[22px] px-1.5 gap-1 rounded-[4px] bg-surface-hover border border-border font-medium text-foreground-secondary">
102
+ {isMac ? <span className="text-[15px]">⌘</span> : <span className="text-[11px]">Ctrl</span>}
103
+ <span className="text-[11px]">K</span>
104
+ </kbd>
105
+ </button>
106
+ )}
107
+ <SearchDialog open={isSearchOpen} onClose={closeSearch} />
108
+ </SearchContext.Provider>
109
+ );
110
+ }
@@ -0,0 +1,269 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Search, Clock, X } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+ import { formatDateTime } from "@/lib/date-utils";
8
+ import { useSearch } from "@/lib/hooks/use-search";
9
+ import type {
10
+ SearchResult,
11
+ SearchResultType,
12
+ MessageSearchResult,
13
+ } from "@/lib/types/search";
14
+
15
+ // ============================================================================
16
+ // Result renderers — one per content type (pluggable registry)
17
+ // ============================================================================
18
+
19
+ function MessageResultRow({ result }: { result: MessageSearchResult }) {
20
+ const timeStr = formatDateTime(result.timestamp);
21
+
22
+ return (
23
+ <div className="flex items-start gap-3 min-w-0">
24
+ <div className="min-w-0 flex-1">
25
+ <div className="flex items-center gap-2 text-xs text-foreground-secondary mb-0.5">
26
+ <span className="px-1.5 py-0.5 rounded bg-accent/10 text-accent font-medium text-[11px]">
27
+ {result.title}
28
+ </span>
29
+ {result.archived && (
30
+ <span className="text-[11px] text-foreground-secondary/50 italic">archived</span>
31
+ )}
32
+ <span className="font-medium">{result.subtitle}</span>
33
+ <span className="text-foreground-secondary/60">{timeStr}</span>
34
+ </div>
35
+ <p className="text-sm text-foreground truncate">{result.snippet}</p>
36
+ </div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ // Registry: map each SearchResultType to its renderer
42
+ const resultRenderers: Record<
43
+ SearchResultType,
44
+ ((result: SearchResult) => ReactNode) | null
45
+ > = {
46
+ message: (r) => <MessageResultRow result={r as MessageSearchResult} />,
47
+ task: null, // Future
48
+ note: null, // Future
49
+ project: null, // Future
50
+ };
51
+
52
+ // ============================================================================
53
+ // SearchDialog
54
+ // ============================================================================
55
+
56
+ interface SearchDialogProps {
57
+ open: boolean;
58
+ onClose: () => void;
59
+ }
60
+
61
+ export function SearchDialog({ open, onClose }: SearchDialogProps) {
62
+ const router = useRouter();
63
+ const inputRef = useRef<HTMLInputElement>(null);
64
+ const listRef = useRef<HTMLDivElement>(null);
65
+ const { query, setQuery, results, isSearching, recentSearches, clearRecentSearches } =
66
+ useSearch();
67
+ const [selectedIndex, setSelectedIndex] = useState(-1);
68
+
69
+ // Focus input on open
70
+ useEffect(() => {
71
+ if (open) {
72
+ // Small delay to ensure the DOM is ready
73
+ requestAnimationFrame(() => inputRef.current?.focus());
74
+ setSelectedIndex(-1);
75
+ }
76
+ }, [open]);
77
+
78
+ // Reset selection when results change
79
+ useEffect(() => {
80
+ setSelectedIndex(-1);
81
+ }, [results]);
82
+
83
+ // Determine what items are shown (results or recent searches)
84
+ const showRecent = !query.trim() && recentSearches.length > 0;
85
+ const itemCount = showRecent ? recentSearches.length : results.length;
86
+
87
+ // Keyboard navigation
88
+ const handleKeyDown = (e: React.KeyboardEvent) => {
89
+ if (e.key === "Escape") {
90
+ e.preventDefault();
91
+ onClose();
92
+ return;
93
+ }
94
+
95
+ if (e.key === "ArrowDown") {
96
+ e.preventDefault();
97
+ setSelectedIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
98
+ return;
99
+ }
100
+
101
+ if (e.key === "ArrowUp") {
102
+ e.preventDefault();
103
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
104
+ return;
105
+ }
106
+
107
+ if (e.key === "Enter" && selectedIndex >= 0) {
108
+ e.preventDefault();
109
+ if (showRecent) {
110
+ // Fill input with the recent search
111
+ setQuery(recentSearches[selectedIndex]);
112
+ } else if (results[selectedIndex]) {
113
+ navigateToResult(results[selectedIndex]);
114
+ }
115
+ return;
116
+ }
117
+ };
118
+
119
+ // Scroll selected item into view
120
+ useEffect(() => {
121
+ if (selectedIndex < 0 || !listRef.current) return;
122
+ const items = listRef.current.querySelectorAll("[data-search-item]");
123
+ items[selectedIndex]?.scrollIntoView({ block: "nearest" });
124
+ }, [selectedIndex]);
125
+
126
+ const navigateToResult = (result: SearchResult) => {
127
+ router.push(result.href);
128
+ // Close after navigation is dispatched — ensures router.push isn't
129
+ // interrupted by the dialog unmounting
130
+ setTimeout(onClose, 0);
131
+ };
132
+
133
+ if (!open) return null;
134
+
135
+ return (
136
+ <div
137
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
138
+ onClick={onClose}
139
+ >
140
+ {/* Backdrop */}
141
+ <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
142
+
143
+ {/* Dialog */}
144
+ <div
145
+ className="relative w-full max-w-[560px] rounded-[var(--radius-md)] bg-surface border border-border shadow-2xl overflow-hidden"
146
+ onClick={(e) => e.stopPropagation()}
147
+ >
148
+ {/* Search input */}
149
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-border">
150
+ <Search className="h-5 w-5 text-foreground-secondary shrink-0" />
151
+ <input
152
+ ref={inputRef}
153
+ type="text"
154
+ value={query}
155
+ onChange={(e) => setQuery(e.target.value)}
156
+ onKeyDown={handleKeyDown}
157
+ placeholder="Search across all channels..."
158
+ className="flex-1 bg-transparent text-[15px] focus:outline-none placeholder:text-foreground-secondary/50"
159
+ />
160
+ <kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[11px] font-medium text-foreground-secondary/60 bg-surface-hover rounded border border-border/50">
161
+ ESC
162
+ </kbd>
163
+ </div>
164
+
165
+ {/* Results / Recent searches */}
166
+ <div ref={listRef} className="max-h-[400px] overflow-y-auto">
167
+ {/* Recent searches (before typing) */}
168
+ {showRecent && (
169
+ <div>
170
+ <div className="flex items-center justify-between px-4 pt-3 pb-1.5">
171
+ <span className="text-xs font-medium text-foreground-secondary/60 uppercase tracking-wider">
172
+ Recent searches
173
+ </span>
174
+ <button
175
+ onClick={clearRecentSearches}
176
+ className="text-xs text-foreground-secondary/60 hover:text-foreground-secondary transition-colors cursor-pointer"
177
+ >
178
+ Clear
179
+ </button>
180
+ </div>
181
+ {recentSearches.map((recent, i) => (
182
+ <button
183
+ key={recent}
184
+ data-search-item
185
+ onClick={() => setQuery(recent)}
186
+ className={cn(
187
+ "flex items-center gap-3 w-full px-4 py-2.5 text-sm text-left transition-colors cursor-pointer",
188
+ selectedIndex === i
189
+ ? "bg-accent/10 text-accent"
190
+ : "text-foreground hover:bg-surface-hover"
191
+ )}
192
+ >
193
+ <Clock className="h-4 w-4 text-foreground-secondary/60 shrink-0" />
194
+ <span className="truncate">{recent}</span>
195
+ </button>
196
+ ))}
197
+ </div>
198
+ )}
199
+
200
+ {/* Empty state — no query, no recent */}
201
+ {!query.trim() && recentSearches.length === 0 && (
202
+ <div className="px-4 py-8 text-center text-sm text-foreground-secondary/60">
203
+ Search across all channels
204
+ </div>
205
+ )}
206
+
207
+ {/* Loading skeleton */}
208
+ {query.trim() && isSearching && results.length === 0 && (
209
+ <div className="px-2">
210
+ {[1, 2, 3, 4, 5].map((i) => (
211
+ <div key={i} className="px-2 py-3 border-b border-border/20 last:border-b-0">
212
+ <div className="flex items-center gap-2 mb-1.5">
213
+ <div className="skeleton h-4 w-16 rounded" />
214
+ <div className="skeleton h-3 w-12 rounded" />
215
+ <div className="skeleton h-3 w-10 rounded" />
216
+ </div>
217
+ <div className="skeleton h-3.5 rounded" style={{ width: `${55 + i * 8}%` }} />
218
+ </div>
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ {/* Results */}
224
+ {query.trim() && !isSearching && results.length === 0 && (
225
+ <div className="px-4 py-8 text-center text-sm text-foreground-secondary">
226
+ No results found
227
+ </div>
228
+ )}
229
+
230
+ {query.trim() &&
231
+ results.map((result, i) => {
232
+ const renderer = resultRenderers[result.type];
233
+ if (!renderer) return null;
234
+ return (
235
+ <button
236
+ key={result.id}
237
+ data-search-item
238
+ onClick={() => navigateToResult(result)}
239
+ className={cn(
240
+ "w-full px-4 py-3 text-left transition-colors cursor-pointer border-b border-border/20 last:border-b-0",
241
+ selectedIndex === i
242
+ ? "bg-accent/10"
243
+ : "hover:bg-surface-hover"
244
+ )}
245
+ >
246
+ {renderer(result)}
247
+ </button>
248
+ );
249
+ })}
250
+ </div>
251
+
252
+ {/* Footer hint */}
253
+ {query.trim() && results.length > 0 && (
254
+ <div className="px-4 py-2 border-t border-border/50 text-[11px] text-foreground-secondary/50 flex items-center gap-3">
255
+ <span>
256
+ <kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">↑↓</kbd> Navigate
257
+ </span>
258
+ <span>
259
+ <kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">↵</kbd> Open
260
+ </span>
261
+ <span>
262
+ <kbd className="px-1 py-0.5 rounded bg-surface-hover border border-border/50 text-[10px]">esc</kbd> Close
263
+ </span>
264
+ </div>
265
+ )}
266
+ </div>
267
+ </div>
268
+ );
269
+ }