Skip to content

Commit a67f168

Browse files
kevincodex1Kevin Codex
authored andcommitted
implemented redesigned UI/UX
1 parent dba2a26 commit a67f168

39 files changed

Lines changed: 3101 additions & 217 deletions

‎package-lock.json‎

Lines changed: 659 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,19 @@
1515
"@radix-ui/react-tabs": "^1.1.14",
1616
"class-variance-authority": "^0.7.1",
1717
"clsx": "^2.1.1",
18+
"dompurify": "^3.4.11",
19+
"gemoji": "^8.1.0",
1820
"lucide-react": "^1.17.0",
21+
"marked": "^18.0.5",
22+
"marked-alert": "^2.1.2",
23+
"marked-emoji": "^2.0.3",
24+
"marked-footnote": "^1.4.0",
25+
"marked-gfm-heading-id": "^4.1.4",
26+
"marked-highlight": "^2.2.4",
1927
"react": "^19.2.6",
2028
"react-dom": "^19.2.6",
2129
"react-router-dom": "^7.17.0",
30+
"shiki": "^4.3.0",
2231
"tailwind-merge": "^3.6.0"
2332
},
2433
"devDependencies": {

‎src/components/agents/AgentRow.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function AgentRow({ agent, index }: AgentRowProps) {
1515
return (
1616
<li
1717
className="grid grid-cols-[16px_minmax(0,1fr)] md:grid-cols-[24px_minmax(0,4fr)_minmax(0,3fr)_140px_110px]
18-
items-start gap-x-3 md:gap-x-4 px-4 sm:px-6 py-4
18+
items-start gap-x-3 md:gap-x-4 px-4 sm:px-6 py-3
1919
border-b border-border-inner last:border-b-0 hover:bg-hover transition-colors
2020
animate-fade-up motion-reduce:animate-none"
2121
style={{ animationDelay: `${index * 16}ms` }}
@@ -35,7 +35,7 @@ export function AgentRow({ agent, index }: AgentRowProps) {
3535
<span className="text-[14px] font-bold text-foreground">{shortDid(agent.did)}</span>
3636
<span className="text-[11px] text-dim">{agent.status}</span>
3737
<CopyButton value={agent.did} label="did" />
38-
<Pill to={`/repos?owner=${encodeURIComponent(didKeySegment(agent.did))}`}>
38+
<Pill to={`/repos?owner=${encodeURIComponent(didKeySegment(agent.did))}`} data-row-link="">
3939
repos →
4040
</Pill>
4141
</div>
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { useState, useEffect, useMemo } from 'react';
2+
import { useNavigate, useLocation, matchPath } from 'react-router-dom';
3+
import { fetchRepos, shortDid, SERVER_SEARCH_ENABLED } from '../../lib/api';
4+
import type { ApiRepo } from '../../lib/api';
5+
import { getCachedAgents } from '../../hooks/useAgents';
6+
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
7+
import { useShortcuts } from '../../hooks/useShortcuts';
8+
import { cn } from '../../lib/utils';
9+
import { Modal } from '../ui/Modal';
10+
11+
interface PaletteItem {
12+
id: string;
13+
section: string;
14+
label: string;
15+
hint?: string;
16+
run: () => void;
17+
}
18+
19+
// One-shot session cache for the client-side repo list (server q= not
20+
// available until the node's search deploy; see SERVER_SEARCH_ENABLED).
21+
let recentReposCache: ApiRepo[] | null = null;
22+
let recentReposPromise: Promise<ApiRepo[]> | null = null;
23+
24+
function loadRecentRepos(): Promise<ApiRepo[]> {
25+
if (recentReposCache) return Promise.resolve(recentReposCache);
26+
if (!recentReposPromise) {
27+
recentReposPromise = fetchRepos({ limit: 200, offset: 0 })
28+
.then(({ repos }) => {
29+
recentReposCache = repos;
30+
recentReposPromise = null;
31+
return repos;
32+
})
33+
.catch(err => {
34+
recentReposPromise = null;
35+
throw err;
36+
});
37+
}
38+
return recentReposPromise;
39+
}
40+
41+
const TAB_IDS = ['code', 'commits', 'pulls', 'issues', 'certs', 'events'];
42+
43+
interface CommandPaletteProps {
44+
open: boolean;
45+
onClose: () => void;
46+
}
47+
48+
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
49+
const navigate = useNavigate();
50+
const { pathname } = useLocation();
51+
const { setOpenModal } = useShortcuts();
52+
const [query, setQuery] = useState('');
53+
const [sel, setSel] = useState(0);
54+
const [repos, setRepos] = useState<ApiRepo[] | null>(SERVER_SEARCH_ENABLED ? [] : recentReposCache);
55+
56+
const debouncedQuery = useDebouncedValue(query.trim(), 250);
57+
const detailMatch = matchPath('/repos/:owner/:name', pathname);
58+
59+
// Reset per open
60+
const [wasOpen, setWasOpen] = useState(open);
61+
if (wasOpen !== open) {
62+
setWasOpen(open);
63+
if (open) {
64+
setQuery('');
65+
setSel(0);
66+
}
67+
}
68+
69+
// Repo source: server search when available, else one-shot recent list
70+
useEffect(() => {
71+
if (!open) return;
72+
if (SERVER_SEARCH_ENABLED) {
73+
const ctrl = new AbortController();
74+
fetchRepos({ limit: 20, offset: 0, q: debouncedQuery || undefined, signal: ctrl.signal })
75+
.then(({ repos }) => setRepos(repos))
76+
.catch(() => {});
77+
return () => ctrl.abort();
78+
}
79+
let cancelled = false;
80+
loadRecentRepos()
81+
.then(list => { if (!cancelled) setRepos(list); })
82+
.catch(() => { if (!cancelled) setRepos([]); });
83+
return () => { cancelled = true; };
84+
}, [open, debouncedQuery]);
85+
86+
const items = useMemo<PaletteItem[]>(() => {
87+
const q = query.trim().toLowerCase();
88+
const matches = (s: string) => !q || s.toLowerCase().includes(q);
89+
const list: PaletteItem[] = [];
90+
91+
// Actions
92+
if (matches('go to repositories')) {
93+
list.push({ id: 'nav-repos', section: 'actions', label: 'go to repositories', run: () => navigate('/repos') });
94+
}
95+
if (matches('go to agents')) {
96+
list.push({ id: 'nav-agents', section: 'actions', label: 'go to agents', run: () => navigate('/agents') });
97+
}
98+
if (detailMatch) {
99+
if (matches('find file')) {
100+
list.push({
101+
id: 'find-file', section: 'actions', label: 'find file', hint: 't',
102+
run: () => setOpenModal('finder'),
103+
});
104+
}
105+
for (const tab of TAB_IDS) {
106+
if (matches(`open ${tab}`)) {
107+
list.push({
108+
id: `tab-${tab}`, section: 'actions', label: `open: ${tab}`,
109+
run: () => navigate(`${pathname}${tab === 'code' ? '' : `?tab=${tab}`}`),
110+
});
111+
}
112+
}
113+
}
114+
115+
// Repos
116+
const repoRows = (repos ?? []).filter(r =>
117+
SERVER_SEARCH_ENABLED
118+
? true
119+
: !q ||
120+
r.name.toLowerCase().includes(q) ||
121+
r.owner_did.toLowerCase().includes(q) ||
122+
(r.description ?? '').toLowerCase().includes(q),
123+
);
124+
for (const r of repoRows.slice(0, 12)) {
125+
list.push({
126+
id: `repo-${r.id}`,
127+
section: SERVER_SEARCH_ENABLED ? 'repositories' : 'recent repos',
128+
label: `${shortDid(r.owner_did)}/${r.name}`,
129+
hint: r.description ?? undefined,
130+
run: () => navigate(`/repos/${r.owner_did}/${r.name}`),
131+
});
132+
}
133+
134+
// Agents (cache only)
135+
const agents = getCachedAgents();
136+
if (agents && q) {
137+
for (const a of agents.filter(a => a.did.toLowerCase().includes(q)).slice(0, 5)) {
138+
list.push({
139+
id: `agent-${a.did}`,
140+
section: 'agents',
141+
label: shortDid(a.did),
142+
hint: a.capabilities.join(', '),
143+
run: () => navigate(`/repos?owner=${encodeURIComponent(a.did.split(':').pop() ?? '')}`),
144+
});
145+
}
146+
}
147+
148+
return list;
149+
}, [query, repos, detailMatch, pathname, navigate, setOpenModal]);
150+
151+
// Render-phase clamp when items shrink
152+
const itemsKey = `${query}:${items.length}`;
153+
const [prevItemsKey, setPrevItemsKey] = useState(itemsKey);
154+
if (prevItemsKey !== itemsKey) {
155+
setPrevItemsKey(itemsKey);
156+
if (sel >= items.length) setSel(0);
157+
}
158+
159+
const pick = (item: PaletteItem) => {
160+
onClose();
161+
item.run();
162+
};
163+
164+
const handleKeyDown = (e: React.KeyboardEvent) => {
165+
if (e.key === 'ArrowDown') {
166+
e.preventDefault();
167+
setSel(s => Math.min(s + 1, items.length - 1));
168+
} else if (e.key === 'ArrowUp') {
169+
e.preventDefault();
170+
setSel(s => Math.max(s - 1, 0));
171+
} else if (e.key === 'Enter' && items[sel]) {
172+
e.preventDefault();
173+
pick(items[sel]);
174+
}
175+
};
176+
177+
let lastSection = '';
178+
179+
return (
180+
<Modal open={open} onClose={onClose} label="command palette">
181+
<input
182+
autoFocus
183+
type="text"
184+
value={query}
185+
onChange={e => setQuery(e.target.value)}
186+
onKeyDown={handleKeyDown}
187+
placeholder="jump to repo, agent, or action…"
188+
aria-label="command palette"
189+
autoComplete="off"
190+
spellCheck={false}
191+
className="w-full h-12 px-5 text-[13px] bg-transparent border-b border-border
192+
text-foreground placeholder:text-dim focus:outline-none"
193+
/>
194+
<ul className="m-0 p-0 list-none max-h-[50vh] overflow-y-auto" role="listbox" aria-label="commands">
195+
{items.map((item, i) => {
196+
const showSection = item.section !== lastSection;
197+
lastSection = item.section;
198+
return (
199+
<li key={item.id} role="option" aria-selected={i === sel}>
200+
{showSection && (
201+
<div className="micro-label px-5 pt-3 pb-1">{item.section}</div>
202+
)}
203+
<button
204+
type="button"
205+
onClick={() => pick(item)}
206+
onMouseEnter={() => setSel(i)}
207+
className={cn(
208+
'flex w-full items-baseline gap-3 text-left px-5 py-2 text-[12.5px] cursor-pointer',
209+
i === sel ? 'bg-hover text-foreground' : 'text-muted-foreground',
210+
)}
211+
>
212+
<span className={cn('truncate', i === sel && 'font-bold text-foreground')}>{item.label}</span>
213+
{item.hint && <span className="ml-auto shrink-0 max-w-[45%] truncate text-[11px] text-dim">{item.hint}</span>}
214+
</button>
215+
</li>
216+
);
217+
})}
218+
{items.length === 0 && (
219+
<li className="px-5 py-6 text-center text-[12.5px] text-muted-foreground">nothing matches</li>
220+
)}
221+
</ul>
222+
<div className="flex items-center justify-between px-5 h-9 border-t border-border text-[11px] text-dim">
223+
<span>{!SERVER_SEARCH_ENABLED && 'searching the 200 most recently updated repos'}</span>
224+
<span>↑↓ navigate · ↵ open · esc close</span>
225+
</div>
226+
</Modal>
227+
);
228+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Modal } from '../ui/Modal';
2+
import { MicroLabel } from '../ui/MicroLabel';
3+
4+
function Kbd({ children }: { children: string }) {
5+
return <kbd className="kbd">{children}</kbd>;
6+
}
7+
8+
function Row({ keys, desc }: { keys: string[]; desc: string }) {
9+
return (
10+
<div className="flex items-center justify-between gap-4 py-1.5">
11+
<dt className="m-0 text-[12.5px] text-muted-foreground">{desc}</dt>
12+
<dd className="m-0 flex gap-1 shrink-0">
13+
{keys.map(k => <Kbd key={k}>{k}</Kbd>)}
14+
</dd>
15+
</div>
16+
);
17+
}
18+
19+
interface KeyboardCheatsheetProps {
20+
open: boolean;
21+
onClose: () => void;
22+
}
23+
24+
export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
25+
return (
26+
<Modal open={open} onClose={onClose} label="keyboard shortcuts">
27+
<div className="flex items-center justify-between px-5 h-11 border-b border-border">
28+
<MicroLabel>keyboard shortcuts</MicroLabel>
29+
<button
30+
onClick={onClose}
31+
aria-label="close"
32+
className="text-[12px] text-dim hover:text-foreground cursor-pointer
33+
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-warm"
34+
>
35+
esc
36+
</button>
37+
</div>
38+
<dl className="m-0 grid sm:grid-cols-2 gap-x-10 gap-y-4 p-5">
39+
<div>
40+
<MicroLabel className="block mb-2">global</MicroLabel>
41+
<Row keys={['⌘', 'k']} desc="command palette" />
42+
<Row keys={['?']} desc="this cheatsheet" />
43+
<Row keys={['j', 'k']} desc="move through list rows" />
44+
<Row keys={['↵']} desc="open focused row" />
45+
</div>
46+
<div>
47+
<MicroLabel className="block mb-2">repository</MicroLabel>
48+
<Row keys={['t']} desc="find file" />
49+
<Row keys={['1', '…', '6']} desc="switch tabs" />
50+
<Row keys={['⌫']} desc="up one directory" />
51+
</div>
52+
<div className="sm:col-span-2">
53+
<MicroLabel className="block mb-2">file viewer</MicroLabel>
54+
<Row keys={['y']} desc="copy link to file (with selected lines)" />
55+
<Row keys={['click', '⇧ click']} desc="select line / extend range in gutter" />
56+
</div>
57+
</dl>
58+
</Modal>
59+
);
60+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useState, useRef, useEffect, useMemo, useCallback, type ReactNode } from 'react';
2+
import { ShortcutsContext, isTypingTarget, type ModalId, type ShortcutHandler } from '../../lib/shortcuts';
3+
import { CommandPalette } from './CommandPalette';
4+
import { KeyboardCheatsheet } from './KeyboardCheatsheet';
5+
6+
export function ShortcutsProvider({ children }: { children: ReactNode }) {
7+
const [openModal, setOpenModal] = useState<ModalId>(null);
8+
const registryRef = useRef(new Map<string, ShortcutHandler>());
9+
10+
const register = useCallback((key: string, handler: ShortcutHandler) => {
11+
registryRef.current.set(key, handler);
12+
return () => {
13+
if (registryRef.current.get(key) === handler) registryRef.current.delete(key);
14+
};
15+
}, []);
16+
17+
useEffect(() => {
18+
const onKeyDown = (e: KeyboardEvent) => {
19+
// Palette toggle works everywhere, even while typing
20+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
21+
e.preventDefault();
22+
setOpenModal(m => (m === 'palette' ? null : 'palette'));
23+
return;
24+
}
25+
// Open modals own their keys (Modal handles Escape)
26+
if (openModal !== null) return;
27+
if (isTypingTarget(e) || e.metaKey || e.ctrlKey || e.altKey) return;
28+
29+
if (e.key === '?') {
30+
e.preventDefault();
31+
setOpenModal('cheatsheet');
32+
return;
33+
}
34+
registryRef.current.get(e.key)?.(e);
35+
};
36+
window.addEventListener('keydown', onKeyDown);
37+
return () => window.removeEventListener('keydown', onKeyDown);
38+
}, [openModal]);
39+
40+
const ctx = useMemo(
41+
() => ({ openModal, setOpenModal, register }),
42+
[openModal, register],
43+
);
44+
45+
return (
46+
<ShortcutsContext.Provider value={ctx}>
47+
{children}
48+
<CommandPalette open={openModal === 'palette'} onClose={() => setOpenModal(null)} />
49+
<KeyboardCheatsheet open={openModal === 'cheatsheet'} onClose={() => setOpenModal(null)} />
50+
</ShortcutsContext.Provider>
51+
);
52+
}

0 commit comments

Comments
 (0)