@castlekit/castle 0.1.6 → 0.3.0

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 (60) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +20 -3
  4. package/src/app/api/avatars/[id]/route.ts +57 -7
  5. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  6. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  7. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  8. package/src/app/api/openclaw/chat/route.ts +272 -0
  9. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  10. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  11. package/src/app/api/openclaw/logs/route.ts +17 -3
  12. package/src/app/api/openclaw/restart/route.ts +6 -1
  13. package/src/app/api/openclaw/session/status/route.ts +42 -0
  14. package/src/app/api/settings/avatar/route.ts +190 -0
  15. package/src/app/api/settings/route.ts +88 -0
  16. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  17. package/src/app/chat/[channelId]/page.tsx +305 -0
  18. package/src/app/chat/layout.tsx +96 -0
  19. package/src/app/chat/page.tsx +52 -0
  20. package/src/app/globals.css +89 -2
  21. package/src/app/layout.tsx +7 -1
  22. package/src/app/page.tsx +49 -17
  23. package/src/app/settings/page.tsx +300 -0
  24. package/src/components/chat/agent-mention-popup.tsx +89 -0
  25. package/src/components/chat/archived-channels.tsx +190 -0
  26. package/src/components/chat/channel-list.tsx +140 -0
  27. package/src/components/chat/chat-input.tsx +310 -0
  28. package/src/components/chat/create-channel-dialog.tsx +171 -0
  29. package/src/components/chat/markdown-content.tsx +205 -0
  30. package/src/components/chat/message-bubble.tsx +152 -0
  31. package/src/components/chat/message-list.tsx +508 -0
  32. package/src/components/chat/message-queue.tsx +68 -0
  33. package/src/components/chat/session-divider.tsx +61 -0
  34. package/src/components/chat/session-stats-panel.tsx +139 -0
  35. package/src/components/chat/storage-indicator.tsx +76 -0
  36. package/src/components/layout/sidebar.tsx +126 -45
  37. package/src/components/layout/user-menu.tsx +29 -4
  38. package/src/components/providers/presence-provider.tsx +8 -0
  39. package/src/components/providers/search-provider.tsx +81 -0
  40. package/src/components/search/search-dialog.tsx +269 -0
  41. package/src/components/ui/avatar.tsx +11 -9
  42. package/src/components/ui/dialog.tsx +10 -4
  43. package/src/components/ui/tooltip.tsx +25 -8
  44. package/src/components/ui/twemoji-text.tsx +37 -0
  45. package/src/lib/api-security.ts +125 -0
  46. package/src/lib/date-utils.ts +79 -0
  47. package/src/lib/db/__tests__/queries.test.ts +318 -0
  48. package/src/lib/db/index.ts +642 -0
  49. package/src/lib/db/queries.ts +1017 -0
  50. package/src/lib/db/schema.ts +160 -0
  51. package/src/lib/hooks/use-agent-status.ts +251 -0
  52. package/src/lib/hooks/use-chat.ts +775 -0
  53. package/src/lib/hooks/use-openclaw.ts +105 -70
  54. package/src/lib/hooks/use-search.ts +113 -0
  55. package/src/lib/hooks/use-session-stats.ts +57 -0
  56. package/src/lib/hooks/use-user-settings.ts +46 -0
  57. package/src/lib/types/chat.ts +186 -0
  58. package/src/lib/types/search.ts +60 -0
  59. package/src/middleware.ts +52 -0
  60. package/vitest.config.ts +13 -0
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown, ChevronUp, Brain, Zap } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import type { SessionStatus } from "@/lib/types/chat";
7
+
8
+ interface SessionStatsPanelProps {
9
+ stats: SessionStatus | null;
10
+ isLoading?: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ /** Simple relative time helper — avoids adding date-fns dependency */
15
+ function timeAgo(timestamp: number): string {
16
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
17
+ if (seconds < 5) return "just now";
18
+ if (seconds < 60) return `${seconds}s ago`;
19
+ const minutes = Math.floor(seconds / 60);
20
+ if (minutes < 60) return `${minutes}m ago`;
21
+ const hours = Math.floor(minutes / 60);
22
+ if (hours < 24) return `${hours}h ago`;
23
+ const days = Math.floor(hours / 24);
24
+ return `${days}d ago`;
25
+ }
26
+
27
+ /** Get context bar color based on usage percentage */
28
+ function getContextColor(percentage: number): string {
29
+ if (percentage >= 90) return "bg-error";
30
+ if (percentage >= 80) return "bg-orange-500";
31
+ if (percentage >= 60) return "bg-yellow-500";
32
+ return "bg-success";
33
+ }
34
+
35
+ function formatTokens(n: number): string {
36
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
37
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
38
+ return String(n);
39
+ }
40
+
41
+ export function SessionStatsPanel({ stats, isLoading, className }: SessionStatsPanelProps) {
42
+ const [expanded, setExpanded] = useState(false);
43
+
44
+ if (!stats && !isLoading) return null;
45
+
46
+ const percentage = stats?.context?.percentage ?? 0;
47
+ const contextColor = getContextColor(percentage);
48
+
49
+ return (
50
+ <div className={cn("border-b border-border", className)}>
51
+ <button
52
+ type="button"
53
+ onClick={() => setExpanded(!expanded)}
54
+ className="w-full px-4 py-2 flex items-center justify-between text-xs text-foreground-secondary hover:bg-surface-hover/50 transition-colors"
55
+ >
56
+ {/* Collapsed view */}
57
+ {stats ? (
58
+ <div className="flex items-center gap-3 min-w-0">
59
+ <span className="flex items-center gap-1">
60
+ <span className={cn("w-2 h-2 rounded-full", contextColor)} />
61
+ Context: {formatTokens(stats.context.used)}/{formatTokens(stats.context.limit)} ({percentage}%)
62
+ </span>
63
+ <span className="text-foreground-secondary/60">|</span>
64
+ <span>
65
+ Tokens: {formatTokens(stats.tokens.input)} in / {formatTokens(stats.tokens.output)} out
66
+ </span>
67
+ </div>
68
+ ) : (
69
+ <span className="text-foreground-secondary/60">
70
+ {isLoading ? "Loading session stats..." : "No session data"}
71
+ </span>
72
+ )}
73
+ {expanded ? (
74
+ <ChevronUp className="h-3 w-3 shrink-0 ml-2" />
75
+ ) : (
76
+ <ChevronDown className="h-3 w-3 shrink-0 ml-2" />
77
+ )}
78
+ </button>
79
+
80
+ {/* Expanded view */}
81
+ {expanded && stats && (
82
+ <div className="px-4 pb-3 space-y-3">
83
+ {/* Context progress bar */}
84
+ <div>
85
+ <div className="flex items-center justify-between text-xs mb-1">
86
+ <span className="text-foreground-secondary">Context Window</span>
87
+ <span className="font-mono">
88
+ {formatTokens(stats.context.used)} / {formatTokens(stats.context.limit)}
89
+ </span>
90
+ </div>
91
+ <div className="w-full h-2 bg-surface-hover rounded-full overflow-hidden">
92
+ <div
93
+ className={cn("h-full rounded-full transition-all duration-300", contextColor)}
94
+ style={{ width: `${Math.min(percentage, 100)}%` }}
95
+ />
96
+ </div>
97
+ </div>
98
+
99
+ {/* Details grid */}
100
+ <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
101
+ <div className="flex items-center gap-2">
102
+ <span className="text-foreground-secondary">Model</span>
103
+ <span className="font-medium truncate">{stats.model}</span>
104
+ </div>
105
+ <div className="flex items-center gap-2">
106
+ <span className="text-foreground-secondary">Runtime</span>
107
+ <span className="font-medium">{stats.runtime}</span>
108
+ </div>
109
+ <div className="flex items-center gap-2">
110
+ <Brain className="h-3 w-3 text-foreground-secondary" />
111
+ <span className="text-foreground-secondary">Thinking</span>
112
+ <span className="font-medium">{stats.thinking || "off"}</span>
113
+ </div>
114
+ <div className="flex items-center gap-2">
115
+ <Zap className="h-3 w-3 text-foreground-secondary" />
116
+ <span className="text-foreground-secondary">Compactions</span>
117
+ <span className="font-medium">{stats.compactions}</span>
118
+ </div>
119
+ <div className="flex items-center gap-2">
120
+ <span className="text-foreground-secondary">Input tokens</span>
121
+ <span className="font-medium">{stats.tokens.input.toLocaleString()}</span>
122
+ </div>
123
+ <div className="flex items-center gap-2">
124
+ <span className="text-foreground-secondary">Output tokens</span>
125
+ <span className="font-medium">{stats.tokens.output.toLocaleString()}</span>
126
+ </div>
127
+ </div>
128
+
129
+ {/* Updated timestamp */}
130
+ {stats.updatedAt && (
131
+ <div className="text-xs text-foreground-secondary/60 text-right">
132
+ Updated {timeAgo(stats.updatedAt)}
133
+ </div>
134
+ )}
135
+ </div>
136
+ )}
137
+ </div>
138
+ );
139
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { HardDrive, AlertTriangle } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface StorageData {
8
+ messages: number;
9
+ channels: number;
10
+ attachments: number;
11
+ totalAttachmentBytes: number;
12
+ dbSizeBytes: number;
13
+ attachmentsDirBytes: number;
14
+ warnings: string[];
15
+ }
16
+
17
+ function formatBytes(bytes: number): string {
18
+ if (bytes === 0) return "0 B";
19
+ if (bytes < 1024) return `${bytes} B`;
20
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
21
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
22
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
23
+ }
24
+
25
+ interface StorageIndicatorProps {
26
+ className?: string;
27
+ }
28
+
29
+ export function StorageIndicator({ className }: StorageIndicatorProps) {
30
+ const [data, setData] = useState<StorageData | null>(null);
31
+
32
+ useEffect(() => {
33
+ async function fetchStats() {
34
+ try {
35
+ const res = await fetch("/api/openclaw/chat/storage");
36
+ if (res.ok) {
37
+ setData(await res.json());
38
+ }
39
+ } catch {
40
+ // Silently fail — storage indicator is non-critical
41
+ }
42
+ }
43
+
44
+ fetchStats();
45
+ // Refresh every 5 minutes
46
+ const interval = setInterval(fetchStats, 300000);
47
+ return () => clearInterval(interval);
48
+ }, []);
49
+
50
+ if (!data) return null;
51
+
52
+ const hasWarnings = data.warnings && data.warnings.length > 0;
53
+ const totalSize = (data.dbSizeBytes || 0) + (data.attachmentsDirBytes || 0);
54
+
55
+ return (
56
+ <div className={cn("px-4 py-2 border-t border-border", className)}>
57
+ <div className="flex items-center justify-between text-xs text-foreground-secondary">
58
+ <div className="flex items-center gap-2">
59
+ {hasWarnings ? (
60
+ <AlertTriangle className="h-3 w-3 text-warning" />
61
+ ) : (
62
+ <HardDrive className="h-3 w-3" />
63
+ )}
64
+ <span>
65
+ {data.messages.toLocaleString()} messages · {formatBytes(totalSize)}
66
+ </span>
67
+ </div>
68
+ {hasWarnings && (
69
+ <span className="text-warning text-xs truncate ml-2 max-w-[200px]" title={data.warnings.join("; ")}>
70
+ {data.warnings[0]}
71
+ </span>
72
+ )}
73
+ </div>
74
+ </div>
75
+ );
76
+ }
@@ -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,81 @@
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
+ // Provider
30
+ // ============================================================================
31
+
32
+ export function SearchProvider({ children }: { children: React.ReactNode }) {
33
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
34
+ const [isMac, setIsMac] = useState(true);
35
+ const pathname = usePathname();
36
+
37
+ // Detect platform for keyboard shortcut display
38
+ useEffect(() => {
39
+ setIsMac(navigator.platform?.toUpperCase().includes("MAC") ?? true);
40
+ }, []);
41
+
42
+ // Hide the floating search trigger on pages where it would overlap or isn't relevant
43
+ const showSearchBar = pathname !== "/settings" && pathname !== "/ui-kit";
44
+
45
+ const openSearch = useCallback(() => setIsSearchOpen(true), []);
46
+ const closeSearch = useCallback(() => setIsSearchOpen(false), []);
47
+
48
+ // Global shortcut: Cmd+K / Ctrl+K to open search
49
+ useEffect(() => {
50
+ const handler = (e: KeyboardEvent) => {
51
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
52
+ e.preventDefault();
53
+ setIsSearchOpen((prev) => !prev);
54
+ }
55
+ };
56
+
57
+ document.addEventListener("keydown", handler);
58
+ return () => document.removeEventListener("keydown", handler);
59
+ }, []);
60
+
61
+ return (
62
+ <SearchContext.Provider value={{ isSearchOpen, openSearch, closeSearch }}>
63
+ {children}
64
+ {/* Floating search trigger — top right (hidden on settings/ui-kit) */}
65
+ {showSearchBar && (
66
+ <button
67
+ onClick={openSearch}
68
+ 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"
69
+ >
70
+ <Search className="h-4 w-4 shrink-0" strokeWidth={2.5} />
71
+ <span className="text-sm text-foreground-secondary/50 flex-1 text-left">Search Castle...</span>
72
+ <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">
73
+ {isMac ? <span className="text-[15px]">⌘</span> : <span className="text-[11px]">Ctrl</span>}
74
+ <span className="text-[11px]">K</span>
75
+ </kbd>
76
+ </button>
77
+ )}
78
+ <SearchDialog open={isSearchOpen} onClose={closeSearch} />
79
+ </SearchContext.Provider>
80
+ );
81
+ }