Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
59 changes: 59 additions & 0 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -130,6 +131,21 @@ export function App() {
const [pendingPrompt, setPendingPrompt] = useState<string>();
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<Detail>();
const refresh = useCallback(async () => {
try {
Expand Down Expand Up @@ -545,6 +561,16 @@ export function App() {
</strong>
</div>
<div className="top-actions">
<button
className="palette-button"
aria-label="Search and commands (Control K)"
title="Search and commands (Ctrl/⌘ K)"
onClick={() => setPalette(true)}
>
<Search size={15} />
<span>Search</span>
<kbd>⌘K</kbd>
</button>
<span className="mode-badge">
{configured ? 'SELF-HOSTED' : 'SETUP REQUIRED'}
</span>
Expand Down Expand Up @@ -953,6 +979,39 @@ export function App() {
mutate={mutate}
/>
)}
{workspace && (
<CommandPalette
open={palette}
spaceId={spaceId || workspace.spaces[0]?.id || ''}
workspace={workspace}
onClose={() => 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);
}}
/>
)}
</div>
);
return configured ? (
Expand Down
305 changes: 305 additions & 0 deletions src/client/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -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<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState('');
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(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<SearchResults>(
`/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 (
<div
className="palette-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
className="palette"
role="dialog"
aria-modal="true"
aria-label="Command palette"
>
<div className="palette-input-row">
<Search size={17} />
<input
ref={inputRef}
role="combobox"
aria-expanded="true"
aria-controls="palette-results"
aria-activedescendant={items[active]?.key}
aria-label="Search pages, Spaces, Dots, and commands"
placeholder="Search pages, Spaces, Dots, or type a command…"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onKeyDown}
/>
<kbd>esc</kbd>
</div>
<div
id="palette-results"
role="listbox"
aria-label="Results"
className="palette-results"
onKeyDown={onKeyDown}
>
{loading && <p className="palette-hint">Searching…</p>}
{!loading && query.trim().length < 2 && (
<p className="palette-hint">
Type at least 2 characters to search across Spaces. Use ↑ ↓ and
Enter.
</p>
)}
{!loading &&
query.trim().length >= 2 &&
items.length === 0 &&
!notice && (
<div className="palette-empty">
<FileText size={26} strokeWidth={1.4} />
<p>No matches for “{query.trim()}”.</p>
<span>Try a title word, a Space name, or a Dot name.</span>
</div>
)}
{items.map((item, index) => {
const header = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
return (
<div key={item.key}>
{header && <p className="palette-group">{header}</p>}
<button
id={item.key}
role="option"
aria-selected={index === active}
className={`palette-item ${index === active ? 'active' : ''}`}
onMouseEnter={() => setActive(index)}
onClick={() => choose(index)}
>
<span className="palette-item-icon">
{item.group === 'Pages' ? (
<FileText size={15} />
) : item.group === 'Spaces' ? (
<Folder size={15} />
) : item.group === 'Dots' ? (
<Sparkles size={15} />
) : (
<Plus size={15} />
)}
</span>
<span className="palette-item-text">
<strong>{item.label}</strong>
{item.detail && <small>{item.detail}</small>}
</span>
<ArrowRight size={14} className="palette-item-arrow" />
</button>
</div>
);
})}
</div>
{notice && (
<p className="palette-notice" role="status">
{notice}
</p>
)}
<footer className="palette-footer">
<span>
<kbd>↑</kbd>
<kbd>↓</kbd> navigate
</span>
<span>
<kbd>↵</kbd> open
</span>
<span>
<kbd>esc</kbd> close
</span>
</footer>
</div>
</div>
);
}
Loading