deepspace 0.6.2 → 0.8.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 (63) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +22 -8
  3. package/dist/cli.js +10348 -5025
  4. package/dist/cli.js.map +1 -1
  5. package/dist/feature-installer.js +638 -0
  6. package/dist/feature-installer.js.map +1 -0
  7. package/dist/index.d.ts +229 -116
  8. package/dist/index.js +767 -788
  9. package/dist/index.js.map +1 -1
  10. package/dist/server.d.ts +403 -167
  11. package/dist/server.js +498 -367
  12. package/dist/server.js.map +1 -1
  13. package/dist/testing.d.ts +1 -1
  14. package/dist/testing.js +3 -3
  15. package/dist/testing.js.map +1 -1
  16. package/dist/worker.d.ts +541 -159
  17. package/dist/worker.js +611 -367
  18. package/dist/worker.js.map +1 -1
  19. package/features/ai-chat/feature.json +2 -0
  20. package/features/ai-chat/src/AiChatPage.tsx +2 -3
  21. package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
  22. package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
  23. package/features/ai-chat/src/ChatPanel.tsx +159 -825
  24. package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
  25. package/features/canvas/src/CanvasListPage.tsx +1 -1
  26. package/features/docs/feature.json +4 -8
  27. package/features/docs/src/DocsEditorPage.tsx +33 -326
  28. package/features/docs/src/DocsListPage.tsx +33 -29
  29. package/features/docs/src/InviteDialog.tsx +11 -40
  30. package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
  31. package/features/docs/src/docs-library-types.ts +28 -3
  32. package/features/docs/src/docs-ui.css +1 -4
  33. package/features/docs/src/use-docs-presence-access.ts +330 -0
  34. package/features/file-attachments/feature.json +2 -2
  35. package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
  36. package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
  37. package/features/file-attachments/src/useFilePreview.ts +54 -68
  38. package/features/file-manager/src/FileManagerPage.tsx +1 -1
  39. package/features/integration-test/src/integration.spec.ts +1 -1
  40. package/features/items/feature.json +1 -5
  41. package/features/items/src/ItemsPage.tsx +207 -135
  42. package/features/landing/feature.json +3 -0
  43. package/features/landing/src/LandingPage.tsx +4 -18
  44. package/features/leaderboard/feature.json +1 -5
  45. package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
  46. package/features/messaging/feature.json +0 -4
  47. package/features/messaging/src/ChatMultiPage.tsx +1 -3
  48. package/features/messaging/src/ChatPage.tsx +1 -3
  49. package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
  50. package/features/search-bar/feature.json +8 -3
  51. package/features/search-bar/src/SearchOverlay.tsx +108 -252
  52. package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
  53. package/features/search-bar/src/search-model.ts +223 -0
  54. package/features/tasks/src/TasksPage.tsx +3 -3
  55. package/features/topbar/src/Topbar.tsx +0 -17
  56. package/package.json +1 -2
  57. package/features/docs/src/docs-favorites.ts +0 -18
  58. package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
  59. package/features/file-attachments/src/attachments-constants.ts +0 -9
  60. package/features/items/src/items-constants.ts +0 -10
  61. package/features/leaderboard/src/leaderboard-constants.ts +0 -21
  62. package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
  63. package/scripts/add-feature.cjs +0 -807
@@ -57,10 +57,6 @@
57
57
  "src": "src/components/chat/MessageActionSheet.tsx",
58
58
  "dest": "src/components/messaging/chat/MessageActionSheet.tsx"
59
59
  },
60
- {
61
- "src": "src/components/chat/ReactionPicker.tsx",
62
- "dest": "src/components/messaging/chat/ReactionPicker.tsx"
63
- },
64
60
  {
65
61
  "src": "src/components/chat-multi/ChannelSidebar.tsx",
66
62
  "dest": "src/components/messaging/chat-multi/ChannelSidebar.tsx"
@@ -27,13 +27,11 @@ import { ChannelSettingsPanel } from '../components/messaging/chat-multi/Channel
27
27
 
28
28
  interface ChatMultiPageProps {
29
29
  schemas?: CollectionSchema[]
30
- appId?: string
31
30
  className?: string
32
31
  }
33
32
 
34
33
  export default function ChatMultiPage({
35
34
  schemas = messagingSchemas,
36
- appId = '',
37
35
  className,
38
36
  }: ChatMultiPageProps) {
39
37
  const {
@@ -158,7 +156,7 @@ export default function ChatMultiPage({
158
156
  }`}
159
157
  >
160
158
  {activeChannelId ? (
161
- <RecordScope key={activeChannelId} roomId={`chat:${activeChannelId}`} schemas={schemas} appId={appId}>
159
+ <RecordScope key={activeChannelId} roomId={`chat:${activeChannelId}`} schemas={schemas}>
162
160
  {/* Multi-channel header with back button on mobile */}
163
161
  <div
164
162
  data-testid="multi-chat-header"
@@ -14,14 +14,12 @@ import { MessageList } from '../components/messaging/chat/MessageList'
14
14
 
15
15
  interface ChatPageProps {
16
16
  schemas?: CollectionSchema[]
17
- appId?: string
18
17
  channelName?: string
19
18
  className?: string
20
19
  }
21
20
 
22
21
  export default function ChatPage({
23
22
  schemas = messagingSchemas,
24
- appId = '',
25
23
  channelName = 'general',
26
24
  className,
27
25
  }: ChatPageProps) {
@@ -72,7 +70,7 @@ export default function ChatPage({
72
70
  return (
73
71
  <div className={`flex flex-col h-full ${className ?? ''}`} data-testid="chat-page">
74
72
  <ChatHeader channelId={channelId} />
75
- <RecordScope roomId={`chat:${channelId}`} schemas={schemas} appId={appId}>
73
+ <RecordScope roomId={`chat:${channelId}`} schemas={schemas}>
76
74
  <MessageList channelId={channelId} />
77
75
  </RecordScope>
78
76
  </div>
@@ -5,7 +5,6 @@
5
5
  */
6
6
 
7
7
  import { useEffect, useCallback, useState } from 'react'
8
- import { EMOJI_LIST } from './ReactionPicker'
9
8
  import type { MessageRect } from './MessageItem'
10
9
 
11
10
  interface MessageActionSheetProps {
@@ -22,6 +21,7 @@ interface MessageActionSheetProps {
22
21
 
23
22
  const ACTION_BAR_HEIGHT = 44
24
23
  const VIEWPORT_PADDING = 12
24
+ const EMOJI_LIST = ['👍', '👎', '😄', '🎉', '❤️', '🚀', '👀', '🔥', '💯', '🤔', '😢', '🙏']
25
25
 
26
26
  export function MessageActionSheet({
27
27
  visible,
@@ -41,7 +41,7 @@ export function MessageActionSheet({
41
41
  action()
42
42
  onClose()
43
43
  },
44
- [onClose]
44
+ [onClose],
45
45
  )
46
46
 
47
47
  useEffect(() => {
@@ -84,8 +84,18 @@ export function MessageActionSheet({
84
84
  className="shrink-0 p-1.5 rounded-lg active:bg-muted text-muted-foreground active:text-foreground transition-colors"
85
85
  title="Edit"
86
86
  >
87
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
88
- <path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
87
+ <svg
88
+ className="w-5 h-5"
89
+ fill="none"
90
+ viewBox="0 0 24 24"
91
+ stroke="currentColor"
92
+ strokeWidth={1.5}
93
+ >
94
+ <path
95
+ strokeLinecap="round"
96
+ strokeLinejoin="round"
97
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
98
+ />
89
99
  </svg>
90
100
  </button>
91
101
  <button
@@ -94,8 +104,18 @@ export function MessageActionSheet({
94
104
  className="shrink-0 p-1.5 rounded-lg active:bg-destructive/10 text-muted-foreground active:text-destructive transition-colors"
95
105
  title="Delete"
96
106
  >
97
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
98
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
107
+ <svg
108
+ className="w-5 h-5"
109
+ fill="none"
110
+ viewBox="0 0 24 24"
111
+ stroke="currentColor"
112
+ strokeWidth={1.5}
113
+ >
114
+ <path
115
+ strokeLinecap="round"
116
+ strokeLinejoin="round"
117
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
118
+ />
99
119
  </svg>
100
120
  </button>
101
121
  <div className="shrink-0 w-px h-6 bg-border mx-0.5" />
@@ -107,8 +127,18 @@ export function MessageActionSheet({
107
127
  className="shrink-0 p-1.5 rounded-lg active:bg-muted text-muted-foreground active:text-foreground transition-colors"
108
128
  title="Reply"
109
129
  >
110
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
111
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
130
+ <svg
131
+ className="w-5 h-5"
132
+ fill="none"
133
+ viewBox="0 0 24 24"
134
+ stroke="currentColor"
135
+ strokeWidth={1.5}
136
+ >
137
+ <path
138
+ strokeLinecap="round"
139
+ strokeLinejoin="round"
140
+ d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
141
+ />
112
142
  </svg>
113
143
  </button>
114
144
  <div className="shrink-0 w-px h-6 bg-border mx-0.5" />
@@ -143,9 +173,7 @@ export function MessageActionSheet({
143
173
  onClick={(e) => e.stopPropagation()}
144
174
  >
145
175
  {layout === 'above' && (
146
- <div className="absolute bottom-full mb-2 left-0 w-full">
147
- {actionBar}
148
- </div>
176
+ <div className="absolute bottom-full mb-2 left-0 w-full">{actionBar}</div>
149
177
  )}
150
178
 
151
179
  <div
@@ -157,11 +185,7 @@ export function MessageActionSheet({
157
185
  </p>
158
186
  </div>
159
187
 
160
- {layout === 'below' && (
161
- <div className="mt-2 w-full">
162
- {actionBar}
163
- </div>
164
- )}
188
+ {layout === 'below' && <div className="mt-2 w-full">{actionBar}</div>}
165
189
  </div>
166
190
  </div>
167
191
  )
@@ -3,8 +3,12 @@
3
3
  "name": "Search Bar",
4
4
  "category": "data",
5
5
  "description": "Drop-in InlineSearch/SearchOverlay for app search; use instead of raw SearchInput when showing or filtering results",
6
- "details": "Use this before hand-rolling search over local arrays, feeds, tables, pickers, or RecordRoom records. Installs a component-only search toolkit with lightweight InlineSearch + useSearchIndex for simple in-page filtering, plus SearchOverlay for global/command search, keyboard navigation, grouped results, loading/error/empty states, click selection, optional Enter-to-submit query navigation, opt-in match highlighting, overlay-only recent result and query searches, and a debounced async-search helper. It does not add a route or demo page; import the installed component into the page you are building.",
6
+ "details": "Use this before hand-rolling search over local arrays, feeds, tables, pickers, or RecordRoom records. Installs focused UI and search-model modules for inline or overlay search, ranking, keyboard navigation, grouped results, async search, highlighting, and optional recent searches. It does not add a route or demo page; import the installed modules into the page you are building.",
7
7
  "files": [
8
+ {
9
+ "src": "src/search-model.ts",
10
+ "dest": "src/components/search/search-model.ts"
11
+ },
8
12
  {
9
13
  "src": "src/SearchOverlay.tsx",
10
14
  "dest": "src/components/search/SearchOverlay.tsx"
@@ -12,7 +16,8 @@
12
16
  ],
13
17
  "requires": [],
14
18
  "instructions": [
15
- "Installs src/components/search/SearchOverlay.tsx (exports InlineSearch, SearchOverlay, and useSearchIndex). It adds no route — import what you need into your own page.",
19
+ "Installs src/components/search/SearchOverlay.tsx for UI and src/components/search/search-model.ts for ranking, async search, highlighting, and recent-history behavior. It adds no route.",
20
+ "Import InlineSearch, SearchOverlay, or SearchResultList from @/components/search/SearchOverlay; import useSearchIndex, useAsyncSearch, or getHighlightedParts from @/components/search/search-model.",
16
21
  "Use InlineSearch + useSearchIndex for in-page filtering (lists, feeds, tables, pickers, RecordRoom records); use SearchOverlay for global/command-style search.",
17
22
  "Use the raw SearchInput primitive only for a bare input with no result list, ranking, keyboard navigation, or selection handling."
18
23
  ],
@@ -31,5 +36,5 @@
31
36
  "Use mocked or debounced async providers during prototyping before wiring live integration calls.",
32
37
  "For privacy-sensitive search contexts, set showRecentSearches={false} or use a scoped recentStorageKey per page or account context."
33
38
  ],
34
- "example": "import { InlineSearch, useSearchIndex } from './components/search/SearchOverlay'\n\nconst results = useSearchIndex({\n items: posts,\n query,\n getText: (post) => [post.title, post.body, post.authorName],\n})\n\n<InlineSearch\n query={query}\n onQueryChange={setQuery}\n items={results.map((post) => ({\n id: post.id,\n title: post.title,\n subtitle: post.authorName,\n description: post.body,\n }))}\n onSelect={(item) => openPost(item.id)}\n placeholder=\"Search posts...\"\n/>"
39
+ "example": "import { InlineSearch } from '@/components/search/SearchOverlay'\nimport { useSearchIndex } from '@/components/search/search-model'\n\nconst results = useSearchIndex({\n items: posts,\n query,\n getText: (post) => [post.title, post.body, post.authorName],\n})\n\n<InlineSearch\n query={query}\n onQueryChange={setQuery}\n items={results.map((post) => ({\n id: post.id,\n title: post.title,\n subtitle: post.authorName,\n description: post.body,\n }))}\n onSelect={(item) => openPost(item.id)}\n placeholder=\"Search posts...\"\n/>"
35
40
  }
@@ -1,6 +1,15 @@
1
1
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
- import { Badge, Button, EmptyState, SearchInput, cn } from '@/components/ui'
2
+ import { Button, EmptyState, SearchInput, cn } from '@/components/ui'
3
3
  import { Loader2 } from 'lucide-react'
4
+ import {
5
+ createRecentStorageKey,
6
+ readRecentSearches,
7
+ rememberRecentItem,
8
+ rememberRecentQuery,
9
+ type RecentSearchEntry,
10
+ } from './search-model'
11
+
12
+ const recentQueryMarker = Symbol('recent-query')
4
13
 
5
14
  export interface SearchItem {
6
15
  id: string
@@ -13,12 +22,14 @@ export interface SearchItem {
13
22
  disabled?: boolean
14
23
  }
15
24
 
16
- export interface HighlightedPart {
17
- text: string
18
- match: boolean
25
+ interface RecentQueryItem extends SearchItem {
26
+ [recentQueryMarker]: string
19
27
  }
20
28
 
21
- export type SearchResultRenderer<T extends SearchItem = SearchItem> = (item: T, query: string) => ReactNode
29
+ export type SearchResultRenderer<T extends SearchItem = SearchItem> = (
30
+ item: T,
31
+ query: string,
32
+ ) => ReactNode
22
33
 
23
34
  interface SearchBaseProps<T extends SearchItem = SearchItem> {
24
35
  query: string
@@ -57,115 +68,6 @@ export interface InlineSearchProps<T extends SearchItem = SearchItem> extends Se
57
68
  maxResultsHeightClassName?: string
58
69
  }
59
70
 
60
- export interface UseSearchIndexOptions<T> {
61
- items: T[]
62
- query: string
63
- getText: (item: T) => Array<string | number | null | undefined>
64
- limit?: number
65
- }
66
-
67
- export function useSearchIndex<T>({ items, query, getText, limit = 20 }: UseSearchIndexOptions<T>): T[] {
68
- return useMemo(() => {
69
- const normalizedQuery = normalize(query)
70
- if (!normalizedQuery) return items.slice(0, limit)
71
-
72
- const scored = items
73
- .map((item, index) => {
74
- const haystack = getText(item)
75
- .filter((value): value is string | number => value !== null && value !== undefined)
76
- .map(String)
77
- .join(' ')
78
- const score = scoreText(haystack, normalizedQuery)
79
- return { item, index, score }
80
- })
81
- .filter((entry) => entry.score > 0)
82
- .sort((a, b) => b.score - a.score || a.index - b.index)
83
-
84
- return scored.slice(0, limit).map((entry) => entry.item)
85
- }, [getText, items, limit, query])
86
- }
87
-
88
- export function getHighlightedParts(text: string, query: string): HighlightedPart[] {
89
- const normalizedQuery = normalize(query)
90
- if (!text || !normalizedQuery) return [{ text, match: false }]
91
-
92
- const normalizedText = text.toLowerCase()
93
- const parts: HighlightedPart[] = []
94
- let cursor = 0
95
- let index = normalizedText.indexOf(normalizedQuery)
96
-
97
- while (index !== -1) {
98
- if (index > cursor) {
99
- parts.push({ text: text.slice(cursor, index), match: false })
100
- }
101
- parts.push({ text: text.slice(index, index + normalizedQuery.length), match: true })
102
- cursor = index + normalizedQuery.length
103
- index = normalizedText.indexOf(normalizedQuery, cursor)
104
- }
105
-
106
- if (cursor < text.length) {
107
- parts.push({ text: text.slice(cursor), match: false })
108
- }
109
-
110
- return parts.length > 0 ? parts : [{ text, match: false }]
111
- }
112
-
113
- export interface UseAsyncSearchOptions<T> {
114
- query: string
115
- search: (query: string) => Promise<T[]>
116
- minLength?: number
117
- delayMs?: number
118
- }
119
-
120
- export function useAsyncSearch<T>({
121
- query,
122
- search,
123
- minLength = 2,
124
- delayMs = 250,
125
- }: UseAsyncSearchOptions<T>) {
126
- const [items, setItems] = useState<T[]>([])
127
- const [loading, setLoading] = useState(false)
128
- const [error, setError] = useState<string | null>(null)
129
- const requestId = useRef(0)
130
-
131
- useEffect(() => {
132
- const trimmed = query.trim()
133
- requestId.current += 1
134
- const current = requestId.current
135
-
136
- if (trimmed.length < minLength) {
137
- setItems([])
138
- setLoading(false)
139
- setError(null)
140
- return
141
- }
142
-
143
- setLoading(true)
144
- setError(null)
145
-
146
- const timer = window.setTimeout(() => {
147
- search(trimmed)
148
- .then((results) => {
149
- if (requestId.current !== current) return
150
- setItems(results)
151
- setError(null)
152
- })
153
- .catch((err) => {
154
- if (requestId.current !== current) return
155
- setItems([])
156
- setError(err instanceof Error ? err.message : 'Search failed')
157
- })
158
- .finally(() => {
159
- if (requestId.current === current) setLoading(false)
160
- })
161
- }, delayMs)
162
-
163
- return () => window.clearTimeout(timer)
164
- }, [delayMs, minLength, query, search])
165
-
166
- return { items, loading, error }
167
- }
168
-
169
71
  export function SearchOverlay<T extends SearchItem = SearchItem>({
170
72
  open,
171
73
  onOpenChange,
@@ -193,29 +95,49 @@ export function SearchOverlay<T extends SearchItem = SearchItem>({
193
95
  }: SearchOverlayProps<T>) {
194
96
  const panelRef = useRef<HTMLDivElement>(null)
195
97
  const inputRef = useRef<HTMLInputElement>(null)
196
- const [recentItems, setRecentItems] = useState<SearchItem[]>([])
197
- const resolvedRecentStorageKey = recentStorageKey ?? createRecentStorageKey(title, placeholder, triggerLabel)
98
+ const [recentSearches, setRecentSearches] = useState<RecentSearchEntry[]>([])
99
+ const resolvedRecentStorageKey =
100
+ recentStorageKey ?? createRecentStorageKey(title, placeholder, triggerLabel)
101
+ const recentItems = useMemo(() => {
102
+ const itemsById = new Map(items.map((item) => [item.id, item]))
103
+ return recentSearches.flatMap((recent, index): SearchItem[] => {
104
+ if (recent.kind === 'query') {
105
+ const queryItem: RecentQueryItem = {
106
+ id: `deepspace-recent-query:${index}`,
107
+ title: recent.query,
108
+ subtitle: 'Search query',
109
+ group: 'Recent',
110
+ keywords: [recent.query],
111
+ [recentQueryMarker]: recent.query,
112
+ }
113
+ return [queryItem]
114
+ }
115
+
116
+ const item = itemsById.get(recent.itemId)
117
+ return item ? [{ ...item, group: 'Recent' }] : []
118
+ })
119
+ }, [items, recentSearches])
198
120
  const showRecents = showRecentSearches && query.trim() === '' && recentItems.length > 0
199
- const visibleItems = showRecents
200
- ? recentItems.map((item) => ({ ...item, group: 'Recent' }))
201
- : items
121
+ const visibleItems: SearchItem[] = showRecents ? recentItems : items
202
122
  const canSubmitSearch = Boolean(onSearchSubmit && query.trim())
203
123
 
204
124
  function submitSearch(rawQuery: string) {
205
125
  const trimmed = rawQuery.trim()
206
126
  if (!trimmed || !onSearchSubmit) return
207
- const nextRecentItems = writeRecentSearchQuery(resolvedRecentStorageKey, trimmed, maxRecentItems)
208
- setRecentItems(nextRecentItems)
127
+ if (showRecentSearches) {
128
+ setRecentSearches(rememberRecentQuery(resolvedRecentStorageKey, trimmed, maxRecentItems))
129
+ }
209
130
  onSearchSubmit(trimmed)
210
131
  onOpenChange(false)
211
132
  }
212
133
 
213
134
  useEffect(() => {
214
135
  if (!open) return
215
- window.setTimeout(() => inputRef.current?.focus(), 0)
136
+ const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0)
216
137
  if (showRecentSearches) {
217
- setRecentItems(readRecentSearchItems(resolvedRecentStorageKey, maxRecentItems))
138
+ setRecentSearches(readRecentSearches(resolvedRecentStorageKey, maxRecentItems))
218
139
  }
140
+ return () => window.clearTimeout(focusTimer)
219
141
  }, [maxRecentItems, open, resolvedRecentStorageKey, showRecentSearches])
220
142
 
221
143
  useEffect(() => {
@@ -238,16 +160,24 @@ export function SearchOverlay<T extends SearchItem = SearchItem>({
238
160
  </Button>
239
161
 
240
162
  {open && (
241
- <div className="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm px-3 py-4 sm:py-16" data-testid="search-overlay">
163
+ <div
164
+ className="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm px-3 py-4 sm:py-16"
165
+ data-testid="search-overlay"
166
+ >
242
167
  <div
243
168
  ref={panelRef}
169
+ role="dialog"
170
+ aria-modal="true"
171
+ aria-label={title}
244
172
  className="mx-auto flex max-h-[min(720px,calc(100vh-2rem))] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
245
173
  >
246
174
  <div className="border-b border-border p-4">
247
175
  <div className="mb-3 flex items-start justify-between gap-4">
248
176
  <div>
249
177
  <h2 className="text-lg font-semibold text-foreground">{title}</h2>
250
- {description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
178
+ {description && (
179
+ <p className="mt-1 text-sm text-muted-foreground">{description}</p>
180
+ )}
251
181
  </div>
252
182
  <Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
253
183
  Close
@@ -280,14 +210,24 @@ export function SearchOverlay<T extends SearchItem = SearchItem>({
280
210
  items={visibleItems}
281
211
  query={showRecents ? '' : query}
282
212
  onSelect={(item) => {
283
- if (showRecents && isRecentSearchQueryItem(item)) {
284
- onQueryChange(item.title)
285
- submitSearch(item.title)
213
+ if (showRecents) {
214
+ if (isRecentQueryItem(item)) {
215
+ onQueryChange(item[recentQueryMarker])
216
+ submitSearch(item[recentQueryMarker])
217
+ return
218
+ }
219
+
220
+ const currentItem = items.find((candidate) => candidate.id === item.id)
221
+ if (!currentItem) return
222
+ onSelect(currentItem)
223
+ onOpenChange(false)
286
224
  return
287
225
  }
288
- if (!showRecents) {
289
- const nextRecentItems = writeRecentSearchItem(resolvedRecentStorageKey, item, maxRecentItems)
290
- setRecentItems(nextRecentItems)
226
+
227
+ if (showRecentSearches) {
228
+ setRecentSearches(
229
+ rememberRecentItem(resolvedRecentStorageKey, item.id, maxRecentItems),
230
+ )
291
231
  }
292
232
  onSelect(item as T)
293
233
  onOpenChange(false)
@@ -298,9 +238,21 @@ export function SearchOverlay<T extends SearchItem = SearchItem>({
298
238
  emptyTitle={emptyTitle}
299
239
  emptyDescription={emptyDescription}
300
240
  onSubmitQuery={onSearchSubmit ? submitSearch : undefined}
301
- renderTitle={renderTitle as SearchResultRenderer<SearchItem> | undefined}
302
- renderSubtitle={renderSubtitle as SearchResultRenderer<SearchItem> | undefined}
303
- renderDescription={renderDescription as SearchResultRenderer<SearchItem> | undefined}
241
+ renderTitle={
242
+ showRecents
243
+ ? undefined
244
+ : (renderTitle as SearchResultRenderer<SearchItem> | undefined)
245
+ }
246
+ renderSubtitle={
247
+ showRecents
248
+ ? undefined
249
+ : (renderSubtitle as SearchResultRenderer<SearchItem> | undefined)
250
+ }
251
+ renderDescription={
252
+ showRecents
253
+ ? undefined
254
+ : (renderDescription as SearchResultRenderer<SearchItem> | undefined)
255
+ }
304
256
  />
305
257
  </div>
306
258
  </div>
@@ -347,7 +299,11 @@ export function InlineSearch<T extends SearchItem = SearchItem>({
347
299
  return (
348
300
  <div ref={wrapperRef} className={cn('relative', className)} data-testid="inline-search">
349
301
  {dimContent && showPanel && (
350
- <div className="fixed inset-0 z-30 bg-background/50 backdrop-blur-[2px]" aria-hidden="true" data-testid="inline-search-dimmer" />
302
+ <div
303
+ className="fixed inset-0 z-30 bg-background/50 backdrop-blur-[2px]"
304
+ aria-hidden="true"
305
+ data-testid="inline-search-dimmer"
306
+ />
351
307
  )}
352
308
 
353
309
  <div className="relative z-40">
@@ -374,10 +330,13 @@ export function InlineSearch<T extends SearchItem = SearchItem>({
374
330
  />
375
331
 
376
332
  {showPanel && (
377
- <div className={cn(
378
- 'absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-xl border border-border bg-card shadow-xl',
379
- maxResultsHeightClassName
380
- )} data-testid="inline-search-panel">
333
+ <div
334
+ className={cn(
335
+ 'absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-xl border border-border bg-card shadow-xl',
336
+ maxResultsHeightClassName,
337
+ )}
338
+ data-testid="inline-search-panel"
339
+ >
381
340
  <SearchPanel
382
341
  items={items}
383
342
  query={query}
@@ -485,7 +444,10 @@ function SearchPanel<T extends SearchItem = SearchItem>({
485
444
  tabIndex={-1}
486
445
  >
487
446
  {loading ? (
488
- <div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground" data-testid="search-loading">
447
+ <div
448
+ className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground"
449
+ data-testid="search-loading"
450
+ >
489
451
  <Loader2 className="h-4 w-4 animate-spin" />
490
452
  Searching...
491
453
  </div>
@@ -556,7 +518,7 @@ export function SearchResultList<T extends SearchItem = SearchItem>({
556
518
  className={cn(
557
519
  'flex w-full items-start justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors',
558
520
  active ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
559
- item.disabled && 'cursor-not-allowed opacity-50'
521
+ item.disabled && 'cursor-not-allowed opacity-50',
560
522
  )}
561
523
  data-testid="search-result"
562
524
  data-active={active ? 'true' : 'false'}
@@ -576,7 +538,9 @@ export function SearchResultList<T extends SearchItem = SearchItem>({
576
538
  </span>
577
539
  )}
578
540
  </span>
579
- {item.meta && <span className="shrink-0 text-xs text-muted-foreground">{item.meta}</span>}
541
+ {item.meta && (
542
+ <span className="shrink-0 text-xs text-muted-foreground">{item.meta}</span>
543
+ )}
580
544
  </button>
581
545
  )
582
546
  })}
@@ -587,97 +551,6 @@ export function SearchResultList<T extends SearchItem = SearchItem>({
587
551
  )
588
552
  }
589
553
 
590
- export function toSearchItem(item: SearchItem): SearchItem {
591
- return item
592
- }
593
-
594
- function createRecentStorageKey(title: string, placeholder: string, triggerLabel: string) {
595
- const scope = [title, placeholder, triggerLabel]
596
- .map((value) => value.trim().toLowerCase())
597
- .find(Boolean) ?? 'search'
598
- const normalizedScope = scope.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'search'
599
- return `deepspace:search-bar:recent:${normalizedScope}`
600
- }
601
-
602
- function readRecentSearchItems(storageKey: string, maxItems: number): SearchItem[] {
603
- try {
604
- const raw = window.localStorage.getItem(storageKey)
605
- if (!raw) return []
606
- const parsed = JSON.parse(raw)
607
- if (!Array.isArray(parsed)) return []
608
- return parsed
609
- .filter(isStoredSearchItem)
610
- .slice(0, maxItems)
611
- .map((item) => ({ ...item, group: 'Recent' }))
612
- } catch {
613
- return []
614
- }
615
- }
616
-
617
- function writeRecentSearchItem(storageKey: string, item: SearchItem, maxItems: number): SearchItem[] {
618
- const recentItem = toStoredSearchItem(item)
619
- const next = [
620
- recentItem,
621
- ...readRecentSearchItems(storageKey, maxItems).filter((existing) => existing.id !== recentItem.id),
622
- ].slice(0, maxItems)
623
-
624
- try {
625
- window.localStorage.setItem(storageKey, JSON.stringify(next))
626
- } catch {
627
- // Ignore storage failures; search should still work without persistence.
628
- }
629
-
630
- return next.map((storedItem) => ({ ...storedItem, group: 'Recent' }))
631
- }
632
-
633
- function writeRecentSearchQuery(storageKey: string, query: string, maxItems: number): SearchItem[] {
634
- const recentItem = toStoredSearchQuery(query)
635
- const next = [
636
- recentItem,
637
- ...readRecentSearchItems(storageKey, maxItems).filter((existing) => existing.id !== recentItem.id),
638
- ].slice(0, maxItems)
639
-
640
- try {
641
- window.localStorage.setItem(storageKey, JSON.stringify(next))
642
- } catch {
643
- // Ignore storage failures; search should still work without persistence.
644
- }
645
-
646
- return next.map((storedItem) => ({ ...storedItem, group: 'Recent' }))
647
- }
648
-
649
- function toStoredSearchItem(item: SearchItem): SearchItem {
650
- return {
651
- id: item.id,
652
- title: item.title,
653
- subtitle: item.subtitle,
654
- description: item.description,
655
- group: 'Recent',
656
- keywords: item.keywords,
657
- disabled: item.disabled,
658
- }
659
- }
660
-
661
- function toStoredSearchQuery(query: string): SearchItem {
662
- return {
663
- id: `search-query:${normalize(query)}`,
664
- title: query,
665
- subtitle: 'Search query',
666
- group: 'Recent',
667
- keywords: [query],
668
- }
669
- }
670
-
671
- function isRecentSearchQueryItem(item: SearchItem) {
672
- return item.id.startsWith('search-query:')
673
- }
674
-
675
- function isStoredSearchItem(value: unknown): value is SearchItem {
676
- if (!value || typeof value !== 'object') return false
677
- const candidate = value as Partial<SearchItem>
678
- return typeof candidate.id === 'string' && typeof candidate.title === 'string'
679
- }
680
-
681
554
  function groupItems<T extends SearchItem>(items: T[]): Array<[string, T[]]> {
682
555
  const groups = new Map<string, T[]>()
683
556
  for (const item of items) {
@@ -688,23 +561,6 @@ function groupItems<T extends SearchItem>(items: T[]): Array<[string, T[]]> {
688
561
  return Array.from(groups.entries())
689
562
  }
690
563
 
691
- function normalize(value: string) {
692
- return value.trim().toLowerCase()
693
- }
694
-
695
- function scoreText(value: string, query: string) {
696
- const text = normalize(value)
697
- if (!text || !query) return 0
698
- if (text === query) return 100
699
- if (text.startsWith(query)) return 80
700
- if (text.includes(` ${query}`)) return 60
701
- if (text.includes(query)) return 40
702
-
703
- const terms = query.split(/\s+/).filter(Boolean)
704
- if (terms.length > 1 && terms.every((term) => text.includes(term))) return 20
705
- return 0
706
- }
707
-
708
- export function ResultCountBadge({ count }: { count: number }) {
709
- return <Badge variant="secondary" size="sm">{count} results</Badge>
564
+ function isRecentQueryItem(item: SearchItem): item is RecentQueryItem {
565
+ return recentQueryMarker in item
710
566
  }