diff --git a/README.md b/README.md index 8d00e323..4736f266 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ CopilotKit SDK telemetry collects usage metadata separately from conversation pe | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Spaces and Specialist Dots | Saved names, role instructions, and per-Dot research and memory permissions | | Pages | Searchable library, visual editor, slash commands, autosave, and revision checks | +| Navigation | Global command palette (Ctrl/⌘ K) with ranked cross-Space search over pages, Spaces, and Dots, plus keyboard-first commands | | Conversations | React SDK chat and Threads integration, page-specific conversations, and source links | | Slack | Managed Channels SDK declaration with workspace and user allowlists | | Calls | WebRTC speech, delegated compute, bounded sessions, hangup, and timeline receipts | diff --git a/src/client/App.tsx b/src/client/App.tsx index e5cc6466..6ec84754 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,4 +1,5 @@ import { openPageLink } from './page-navigation'; +import { CommandPalette } from './CommandPalette'; import { SpaceNav } from './SpaceNav'; import { SpaceWorkspace } from './SpaceWorkspace'; import { useCallback, useEffect, useState, useRef } from 'react'; @@ -130,6 +131,21 @@ export function App() { const [pendingPrompt, setPendingPrompt] = useState(); const [busy, setBusy] = useState(false); const [search, setSearch] = useState(''); + const [palette, setPalette] = useState(false); + useEffect(() => { + const shortcut = (event: KeyboardEvent) => { + if ( + (event.metaKey || event.ctrlKey) && + event.key.toLocaleLowerCase() === 'k' && + !event.isComposing + ) { + event.preventDefault(); + setPalette(true); + } + }; + window.addEventListener('keydown', shortcut); + return () => window.removeEventListener('keydown', shortcut); + }, []); const [taskDetail, setTaskDetail] = useState(); const refresh = useCallback(async () => { try { @@ -545,6 +561,16 @@ export function App() {
+ {configured ? 'SELF-HOSTED' : 'SETUP REQUIRED'} @@ -953,6 +979,39 @@ export function App() { mutate={mutate} /> )} + {workspace && ( + setPalette(false)} + onOpenPage={(space, page) => openPage(space, page)} + onOpenSpace={(space) => openPage(space)} + onNewPage={() => + void (async () => { + const target = spaceId || workspace.spaces[0]?.id; + if (!target) return; + try { + const next = await api<{ id: string }>( + `/spaces/${target}/pages`, + 'POST', + { title: 'Untitled page', content: '', parentId: null }, + ); + openPage(target, next.id); + } catch (e) { + setError( + e instanceof Error ? e.message : 'Could not create page.', + ); + } + })() + } + onNewSpace={() => setDialog({ type: 'space' })} + onOpenDot={(dotId) => { + const next = workspace.dots.find((item) => item.id === dotId); + if (next) chooseDot(next); + }} + /> + )}
); return configured ? ( diff --git a/src/client/CommandPalette.tsx b/src/client/CommandPalette.tsx new file mode 100644 index 00000000..7f7eb72e --- /dev/null +++ b/src/client/CommandPalette.tsx @@ -0,0 +1,305 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowRight, + FileText, + Folder, + Plus, + Search, + Sparkles, +} from 'lucide-react'; +import type { SearchResults } from '../server/search'; +import type { WorkspaceState } from '../shared/types'; +import { api } from './api'; + +interface PaletteItem { + key: string; + group: string; + label: string; + detail: string; + run: () => void; +} + +export function CommandPalette({ + open, + spaceId, + workspace, + onClose, + onOpenPage, + onOpenSpace, + onNewPage, + onNewSpace, + onOpenDot, +}: { + open: boolean; + spaceId: string; + workspace: WorkspaceState; + onClose: () => void; + onOpenPage: (space: string, page: string) => void; + onOpenSpace: (space: string) => void; + onNewPage: () => void; + onNewSpace: () => void; + onOpenDot: (dotId: string) => void; +}) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [notice, setNotice] = useState(''); + const [active, setActive] = useState(0); + const inputRef = useRef(null); + const abortRef = useRef(null); + + useEffect(() => { + if (open) { + setQuery(''); + setResults(null); + setNotice(''); + setActive(0); + const focus = () => inputRef.current?.focus(); + if (typeof requestAnimationFrame === 'function') + requestAnimationFrame(focus); + else focus(); + } else { + abortRef.current?.abort(); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const trimmed = query.trim(); + if (trimmed.length < 2) { + abortRef.current?.abort(); + setResults(null); + setLoading(false); + setActive(0); + return; + } + setLoading(true); + const timer = setTimeout(() => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + api( + `/search?q=${encodeURIComponent(trimmed)}&limit=20`, + 'GET', + undefined, + controller.signal, + ) + .then((rows) => { + setResults(rows); + setNotice(''); + setActive(0); + }) + .catch((error) => { + if (error instanceof DOMException && error.name === 'AbortError') + return; + setNotice(error instanceof Error ? error.message : 'Search failed.'); + }) + .finally(() => setLoading(false)); + }, 150); + return () => clearTimeout(timer); + }, [query, open]); + + const commands: PaletteItem[] = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + const all: PaletteItem[] = [ + { + key: 'cmd-new-page', + group: 'Commands', + label: 'New page in this Space', + detail: workspace.spaces.find((s) => s.id === spaceId)?.name ?? '', + run: onNewPage, + }, + { + key: 'cmd-new-space', + group: 'Commands', + label: 'New Space', + detail: 'Organize a new area', + run: onNewSpace, + }, + { + key: 'cmd-go-spaces', + group: 'Commands', + label: `Go to ${workspace.spaces.find((s) => s.id === spaceId)?.name ?? 'Space'} library`, + detail: 'Browse all pages', + run: () => onOpenSpace(spaceId), + }, + ]; + if (!needle) return all; + return all.filter((item) => + `${item.label} ${item.detail}`.toLocaleLowerCase().includes(needle), + ); + }, [query, spaceId, workspace.spaces, onNewPage, onNewSpace, onOpenSpace]); + + const items: PaletteItem[] = useMemo(() => { + const list: PaletteItem[] = []; + for (const page of results?.pages ?? []) { + list.push({ + key: `page-${page.id}`, + group: 'Pages', + label: page.title, + detail: `${page.spaceName}${page.excerpt ? ` · ${page.excerpt.slice(0, 80)}` : ''}`, + run: () => onOpenPage(page.spaceId, page.id), + }); + } + for (const space of results?.spaces ?? []) { + list.push({ + key: `space-${space.id}`, + group: 'Spaces', + label: space.name, + detail: space.description.slice(0, 80), + run: () => onOpenSpace(space.id), + }); + } + for (const dot of results?.dots ?? []) { + list.push({ + key: `dot-${dot.id}`, + group: 'Dots', + label: dot.name, + detail: dot.instructions.slice(0, 80), + run: () => onOpenDot(dot.id), + }); + } + return [...list, ...commands]; + }, [results, commands, onOpenPage, onOpenSpace, onOpenDot]); + + useEffect(() => { + setActive((current) => Math.min(current, Math.max(items.length - 1, 0))); + }, [items.length]); + + if (!open) return null; + + const choose = (index: number) => { + const item = items[index]; + if (!item) return; + onClose(); + item.run(); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + setActive((current) => (items.length ? (current + 1) % items.length : 0)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setActive((current) => + items.length ? (current - 1 + items.length) % items.length : 0, + ); + } else if (event.key === 'Enter') { + event.preventDefault(); + choose(active); + } else if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + }; + + let lastGroup = ''; + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+ + setQuery(event.target.value)} + onKeyDown={onKeyDown} + /> + esc +
+
+ {loading &&

Searching…

} + {!loading && query.trim().length < 2 && ( +

+ Type at least 2 characters to search across Spaces. Use ↑ ↓ and + Enter. +

+ )} + {!loading && + query.trim().length >= 2 && + items.length === 0 && + !notice && ( +
+ +

No matches for “{query.trim()}”.

+ Try a title word, a Space name, or a Dot name. +
+ )} + {items.map((item, index) => { + const header = item.group !== lastGroup ? item.group : null; + lastGroup = item.group; + return ( +
+ {header &&

{header}

} + +
+ ); + })} +
+ {notice && ( +

+ {notice} +

+ )} +
+ + ↑ + ↓ navigate + + + ↵ open + + + esc close + +
+
+
+ ); +} diff --git a/src/client/style.css b/src/client/style.css index 75be32de..58423e93 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -4280,3 +4280,166 @@ h3 { overflow-wrap: anywhere; font-size: 11px; } + +/* Global command palette (Ctrl/Command K) */ +.palette-button { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border, #e2e2e2); + background: transparent; + border-radius: 8px; + padding: 6px 10px; + font-size: 13px; + cursor: pointer; + color: inherit; +} +.palette-button kbd { + font-size: 11px; + opacity: 0.7; + border: 1px solid var(--border, #e2e2e2); + border-radius: 4px; + padding: 0 4px; +} +.palette-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.32); + z-index: 80; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 12vh 16px 16px; +} +.palette { + width: min(640px, 100%); + background: var(--background, #fff); + color: inherit; + border-radius: 14px; + border: 1px solid var(--border, #e2e2e2); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25); + overflow: hidden; +} +.palette-input-row { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--border, #eee); +} +.palette-input-row input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-size: 15px; + color: inherit; +} +.palette-input-row kbd { + font-size: 11px; + opacity: 0.6; + border: 1px solid var(--border, #e2e2e2); + border-radius: 4px; + padding: 0 5px; +} +.palette-results { + max-height: min(50vh, 420px); + overflow: auto; + padding: 8px; +} +.palette-group { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.6; + margin: 10px 8px 4px; +} +.palette-item { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + border: 1px solid transparent; + background: transparent; + border-radius: 9px; + padding: 8px 10px; + cursor: pointer; + text-align: left; + color: inherit; +} +.palette-item.active { + border-color: var(--border, #d5d5d5); + background: rgba(0, 0, 0, 0.04); +} +.palette-item-icon { + opacity: 0.7; + display: inline-flex; +} +.palette-item-text { + flex: 1; + display: grid; + gap: 1px; + min-width: 0; +} +.palette-item-text strong { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.palette-item-text small { + font-size: 12px; + opacity: 0.65; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.palette-item-arrow { + opacity: 0.4; +} +.palette-hint, +.palette-notice { + font-size: 13px; + opacity: 0.75; + padding: 10px 8px; + margin: 0; +} +.palette-empty { + text-align: center; + padding: 22px 12px; + display: grid; + gap: 6px; + justify-items: center; + opacity: 0.85; +} +.palette-empty p { + margin: 0; + font-size: 14px; +} +.palette-empty span { + font-size: 12px; + opacity: 0.7; +} +.palette-footer { + display: flex; + gap: 14px; + padding: 8px 14px; + border-top: 1px solid var(--border, #eee); + font-size: 12px; + opacity: 0.7; +} +.palette-footer kbd { + border: 1px solid var(--border, #e2e2e2); + border-radius: 4px; + padding: 0 5px; + margin-right: 4px; +} +@media (max-width: 700px) { + .palette-backdrop { + padding: 8vh 10px 10px; + } + .palette-button span, + .palette-button kbd { + display: none; + } +} diff --git a/src/server/search.ts b/src/server/search.ts new file mode 100644 index 00000000..4c53ea14 --- /dev/null +++ b/src/server/search.ts @@ -0,0 +1,206 @@ +import type { WorkspaceStore } from './workspace.js'; + +export interface SearchPageHit { + kind: 'page'; + id: string; + spaceId: string; + spaceName: string; + title: string; + excerpt: string; + updatedAt: number; + score: number; +} + +export interface SearchSpaceHit { + kind: 'space'; + id: string; + name: string; + description: string; + score: number; +} + +export interface SearchDotHit { + kind: 'dot'; + id: string; + name: string; + instructions: string; + score: number; +} + +export interface SearchResults { + query: string; + pages: SearchPageHit[]; + spaces: SearchSpaceHit[]; + dots: SearchDotHit[]; +} + +export function tokenize(query: string): string[] { + return query + .toLocaleLowerCase() + .split(/[^a-z0-9]+/i) + .map((token) => token.trim()) + .filter((token) => token.length >= 2) + .slice(0, 8); +} + +function countOccurrences(haystack: string, needle: string): number { + if (!needle) return 0; + let count = 0; + let index = haystack.indexOf(needle); + while (index !== -1) { + count += 1; + index = haystack.indexOf(needle, index + needle.length); + } + return count; +} + +function subsequenceBonus(text: string, token: string): number { + // Cheap fuzzy path: rewards in-order letters (e.g. "pln" ~ "plan"). + let position = 0; + let matched = 0; + for (const char of token) { + const found = text.indexOf(char, position); + if (found === -1) return 0; + matched += 1; + position = found + 1; + } + return matched === token.length ? token.length * 0.5 : 0; +} + +export function scoreText( + title: string, + body: string, + tokens: string[], +): { score: number; matched: number } { + const lowerTitle = title.toLocaleLowerCase(); + const lowerBody = body.toLocaleLowerCase().slice(0, 20000); + let score = 0; + let matched = 0; + for (const token of tokens) { + const inTitle = countOccurrences(lowerTitle, token); + const inBody = countOccurrences(lowerBody, token); + if (inTitle > 0) { + matched += 1; + score += 12 * inTitle; + if (lowerTitle.startsWith(token)) score += 8; + else if (lowerTitle.split(/\s+/).some((word) => word.startsWith(token))) + score += 4; + } else if (inBody > 0) { + matched += 1; + score += Math.min(inBody, 5); + } else { + score += subsequenceBonus(lowerTitle, token); + } + } + return { score, matched }; +} + +export function excerpt( + content: string, + tokens: string[], + length = 160, +): string { + const text = content.replace(/\s+/g, ' ').trim(); + if (!text) return ''; + const lower = text.toLocaleLowerCase(); + let at = -1; + for (const token of tokens) { + const found = lower.indexOf(token); + if (found !== -1 && (at === -1 || found < at)) at = found; + } + const start = at === -1 ? 0 : Math.max(0, at - Math.floor(length / 3)); + const slice = text.slice(start, start + length); + return ( + (start > 0 ? '… ' : '') + slice + (start + length < text.length ? ' …' : '') + ); +} + +export function searchWorkspace( + workspace: WorkspaceStore, + query: string, + limit = 20, +): SearchResults { + const trimmed = query.trim().slice(0, 200); + const tokens = tokenize(trimmed); + const spaces = workspace.spaces(); + const spaceNames = new Map(spaces.map((space) => [space.id, space.name])); + const dots = workspace.dots(); + + if (!tokens.length) + return { query: trimmed, pages: [], spaces: [], dots: [] }; + + const capped = Math.min(Math.max(limit, 1), 50); + + const pageHits: SearchPageHit[] = []; + const listPages = (spaceId: string) => { + try { + return workspace.pages.list(spaceId); + } catch { + return []; + } + }; + for (const space of spaces) { + for (const page of listPages(space.id)) { + const { score, matched } = scoreText( + page.title, + `${space.name} ${page.content}`, + tokens, + ); + if (matched === 0) continue; + // Require multi-token queries to match at least half the tokens. + if (tokens.length > 1 && matched < Math.ceil(tokens.length / 2)) continue; + pageHits.push({ + kind: 'page', + id: page.id, + spaceId: space.id, + spaceName: space.name, + title: page.title, + excerpt: excerpt(page.content, tokens), + updatedAt: page.updatedAt, + score: score + page.updatedAt / 1e13, + }); + } + } + + const spaceHits: SearchSpaceHit[] = []; + for (const space of spaces) { + const { score, matched } = scoreText(space.name, space.description, tokens); + if (matched === 0) continue; + spaceHits.push({ + kind: 'space', + id: space.id, + name: space.name, + description: space.description.slice(0, 200), + score, + }); + } + + const dotHits: SearchDotHit[] = []; + for (const dot of dots) { + const spaceName = spaceNames.get(dot.spaceId) ?? ''; + const { score, matched } = scoreText( + dot.name, + `${dot.instructions} ${spaceName}`, + tokens, + ); + if (matched === 0) continue; + dotHits.push({ + kind: 'dot', + id: dot.id, + name: dot.name, + instructions: dot.instructions.slice(0, 200), + score, + }); + } + + pageHits.sort((a, b) => b.score - a.score || b.updatedAt - a.updatedAt); + spaceHits.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + dotHits.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + + return { + query: trimmed, + pages: pageHits.slice(0, capped), + spaces: spaceHits.slice(0, Math.min(10, capped)), + dots: dotHits.slice(0, Math.min(10, capped)), + }; +} diff --git a/src/server/workspace-routes.ts b/src/server/workspace-routes.ts index 02c11eed..f5e72cfa 100644 --- a/src/server/workspace-routes.ts +++ b/src/server/workspace-routes.ts @@ -1,5 +1,6 @@ import { setupInputSchema } from './setup-telemetry.js'; import { pageRoutes } from './page-routes.js'; +import { searchWorkspace } from './search.js'; import { Hono } from 'hono'; import { z } from 'zod'; import { Platform } from './platform.js'; @@ -48,6 +49,22 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { calls: platform.workspace.calls(), }), ); + app.get('/search', (c) => { + const parsed = z + .object({ + q: z.string().trim().min(2).max(200), + limit: z.coerce.number().int().min(1).max(50).default(20), + }) + .safeParse({ + q: c.req.query('q') ?? '', + limit: c.req.query('limit') ?? undefined, + }); + if (!parsed.success) + return c.json({ error: 'Enter at least 2 characters to search.' }, 400); + return c.json( + searchWorkspace(platform.workspace, parsed.data.q, parsed.data.limit), + ); + }); app.post('/spaces', async (c) => { const data = z .object({ diff --git a/tests/command-palette.test.tsx b/tests/command-palette.test.tsx new file mode 100644 index 00000000..c9f9cde4 --- /dev/null +++ b/tests/command-palette.test.tsx @@ -0,0 +1,114 @@ +import { createElement } from 'react'; +import { act, create } from 'react-test-renderer'; +import { beforeEach, expect, it, vi } from 'vitest'; + +const api = vi.hoisted(() => vi.fn()); +vi.mock('../src/client/api', () => ({ api })); + +import { CommandPalette } from '../src/client/CommandPalette'; + +const workspace = { + spaces: [{ id: 'space-1', name: 'Everyday', description: '' }], + dots: [ + { + id: 'dot-1', + name: 'Scout', + instructions: 'Research carefully', + spaceId: 'space-1', + }, + ], +} as never; + +const props = (overrides: Record = {}) => ({ + open: true, + spaceId: 'space-1', + workspace, + onClose: vi.fn(), + onOpenPage: vi.fn(), + onOpenSpace: vi.fn(), + onNewPage: vi.fn(), + onNewSpace: vi.fn(), + onOpenDot: vi.fn(), + ...overrides, +}); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +beforeEach(() => { + api.mockReset(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); +}); + +it('renders nothing when closed', async () => { + let root!: ReturnType; + await act(async () => { + root = create(createElement(CommandPalette, props({ open: false }))); + }); + expect(root.toJSON()).toBeNull(); +}); + +it('lists commands before searching and runs one with Enter', async () => { + const p = props(); + let root!: ReturnType; + await act(async () => { + root = create(createElement(CommandPalette, p)); + }); + const input = root.root.findByProps({ role: 'combobox' }); + expect(root.root.findAllByProps({ role: 'option' }).length).toBeGreaterThan( + 0, + ); + await act(async () => { + input.props.onKeyDown({ key: 'Enter', preventDefault: () => {} }); + }); + expect(p.onNewPage).toHaveBeenCalledTimes(1); + expect(p.onClose).toHaveBeenCalledTimes(1); +}); + +it('searches across the workspace and navigates with the keyboard', async () => { + api.mockResolvedValue({ + query: 'launch', + pages: [ + { + kind: 'page', + id: 'page-1', + spaceId: 'space-1', + spaceName: 'Everyday', + title: 'Launch plan', + excerpt: 'launch steps', + updatedAt: 2, + score: 30, + }, + ], + spaces: [], + dots: [], + }); + const p = props(); + let root!: ReturnType; + await act(async () => { + root = create(createElement(CommandPalette, p)); + }); + const input = root.root.findByProps({ role: 'combobox' }); + await act(async () => { + input.props.onChange({ target: { value: 'launch' } }); + }); + await act(async () => { + await sleep(300); + }); + expect(api).toHaveBeenCalledWith( + '/search?q=launch&limit=20', + 'GET', + undefined, + expect.anything(), + ); + const options = root.root.findAllByProps({ role: 'option' }); + expect(options.length).toBeGreaterThan(0); + expect(options[0].props['aria-selected']).toBe(true); + + await act(async () => { + input.props.onKeyDown({ key: 'ArrowDown', preventDefault: () => {} }); + }); + await act(async () => { + input.props.onKeyDown({ key: 'Escape', preventDefault: () => {} }); + }); + expect(p.onClose).toHaveBeenCalled(); +}); diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 00000000..c8f6cd9d --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,122 @@ +import { afterEach, expect, it } from 'vitest'; +import { Store } from '../src/server/store.js'; +import { WorkspaceStore } from '../src/server/workspace.js'; +import { Platform } from '../src/server/platform.js'; +import { Runner } from '../src/server/runner.js'; +import { createApp } from '../src/server/app.js'; +import { + excerpt, + scoreText, + searchWorkspace, + tokenize, +} from '../src/server/search.js'; + +const cleanup: (() => void)[] = []; +afterEach(() => cleanup.splice(0).forEach((fn) => fn())); + +function fixture(ownerToken?: string) { + const store = new Store(':memory:'); + const ws = new WorkspaceStore(':memory:', 'owner'); + cleanup.push(() => { + store.close(); + ws.close(); + }); + const config = { mode: 'live' as const, baseUrl: 'https://example.com' }; + const platform = new Platform(store, ws, { + baseUrl: config.baseUrl, + voiceName: 'marin', + slackUsers: [], + runtimeUrl: '', + }); + return { + ws, + app: createApp({ + store, + runner: new Runner(store, config), + config, + platform, + ownerToken, + }), + }; +} + +it('tokenizes queries into bounded lowercase terms', () => { + expect(tokenize(' Plan for Q3! ')).toEqual(['plan', 'for', 'q3']); + expect(tokenize('a b c')).toEqual([]); + expect(tokenize('one two three four five six seven eight nine')).toHaveLength( + 8, + ); +}); + +it('prefers title and prefix matches over body matches', () => { + const tokens = ['plan']; + const title = scoreText('Plan quarterly review', 'other words here', tokens); + const body = scoreText('Something else', 'a long plan document', tokens); + expect(title.score).toBeGreaterThan(body.score); + expect(scoreText('Unrelated', 'nothing here', tokens).matched).toBe(0); +}); + +it('builds a bounded excerpt around the first match', () => { + expect(excerpt('', ['plan'])).toBe(''); + const content = `Intro words. The plan continues with details. ${'x '.repeat(200)}`; + const cut = excerpt(content, ['plan'], 60); + expect(cut.length).toBeLessThanOrEqual(66); + expect(cut.toLocaleLowerCase()).toContain('plan'); +}); + +it('ranks pages, spaces, and dots across the whole workspace', () => { + const { ws } = fixture(); + const first = ws.spaces()[0].id; + const second = ws.createSpace('Launch planning', 'Release notes').id; + ws.pages.create(first, { title: 'Grocery list', content: 'milk eggs' }); + const target = ws.pages.create(second, { + title: 'Launch plan', + content: 'The launch plan covers rollout steps.', + }); + + const results = searchWorkspace(ws, 'launch plan', 20); + expect(results.pages[0].id).toBe(target.id); + expect(results.pages[0].spaceName).toBe('Launch planning'); + expect(results.pages[0].excerpt.toLocaleLowerCase()).toContain('launch'); + expect(results.spaces.map((space) => space.id)).toContain(second); + expect(results.query).toBe('launch plan'); +}); + +it('requires enough token overlap for multi-word queries', () => { + const { ws } = fixture(); + const space = ws.spaces()[0].id; + ws.pages.create(space, { title: 'Cats', content: 'cats cats cats' }); + expect(searchWorkspace(ws, 'cats dogs birds fish', 20).pages).toHaveLength(0); +}); + +it('serves search over the owner API with validation and limits', async () => { + const { ws, app } = fixture(); + const space = ws.spaces()[0].id; + ws.pages.create(space, { title: 'Alpha page', content: 'alpha content' }); + ws.pages.create(space, { title: 'Beta page', content: 'beta content' }); + + expect((await app.request('/api/search')).status).toBe(400); + expect((await app.request('/api/search?q=a')).status).toBe(400); + const ok = await app.request('/api/search?q=alpha&limit=5'); + expect(ok.status).toBe(200); + const body = await ok.json(); + expect(body.pages.map((page: { title: string }) => page.title)).toEqual([ + 'Alpha page', + ]); + expect(body.pages[0].excerpt.length).toBeLessThanOrEqual(166); + + const capped = await app.request('/api/search?q=page&limit=500'); + expect(capped.status).toBe(400); +}); + +it('keeps search behind owner authentication', async () => { + const { app } = fixture('owner-secret'); + expect((await app.request('/api/search?q=alpha')).status).toBe(401); + expect( + ( + await app.request('/api/search?q=alpha', { + headers: { Authorization: 'Bearer owner-secret' }, + }) + ).status, + ).toBe(200); +});