Skip to content

Commit 113c149

Browse files
author
catlog22
committed
feat: add CommandCombobox component for selecting slash commands and update PropertyPanel to use it
refactor: remove unused liteTasks localization from common.json and zh/common.json refactor: consolidate liteTasks localization into lite-tasks.json and zh/lite-tasks.json refactor: simplify MultiCliTab type in LiteTaskDetailPage refactor: enhance task display in LiteTasksPage with additional metadata
1 parent 7b2ac46 commit 113c149

8 files changed

Lines changed: 458 additions & 314 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// ========================================
2+
// Command Combobox Component
3+
// ========================================
4+
// Searchable dropdown for selecting slash commands
5+
6+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
7+
import { ChevronDown, Search } from 'lucide-react';
8+
import { cn } from '@/lib/utils';
9+
import { useCommands } from '@/hooks/useCommands';
10+
import type { Command } from '@/lib/api';
11+
12+
interface CommandComboboxProps {
13+
value: string;
14+
onChange: (value: string) => void;
15+
placeholder?: string;
16+
className?: string;
17+
}
18+
19+
export function CommandCombobox({ value, onChange, placeholder, className }: CommandComboboxProps) {
20+
const [open, setOpen] = useState(false);
21+
const [search, setSearch] = useState('');
22+
const containerRef = useRef<HTMLDivElement>(null);
23+
const inputRef = useRef<HTMLInputElement>(null);
24+
25+
const { commands, isLoading } = useCommands({
26+
filter: { showDisabled: false },
27+
});
28+
29+
// Group commands by group field
30+
const groupedFiltered = useMemo(() => {
31+
const filtered = search
32+
? commands.filter(
33+
(c) =>
34+
c.name.toLowerCase().includes(search.toLowerCase()) ||
35+
c.description.toLowerCase().includes(search.toLowerCase()) ||
36+
c.aliases?.some((a) => a.toLowerCase().includes(search.toLowerCase()))
37+
)
38+
: commands;
39+
40+
const groups: Record<string, Command[]> = {};
41+
for (const cmd of filtered) {
42+
const group = cmd.group || 'other';
43+
if (!groups[group]) groups[group] = [];
44+
groups[group].push(cmd);
45+
}
46+
return groups;
47+
}, [commands, search]);
48+
49+
const totalFiltered = useMemo(
50+
() => Object.values(groupedFiltered).reduce((sum, cmds) => sum + cmds.length, 0),
51+
[groupedFiltered]
52+
);
53+
54+
// Close on outside click
55+
useEffect(() => {
56+
function handleClickOutside(e: MouseEvent) {
57+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
58+
setOpen(false);
59+
}
60+
}
61+
if (open) {
62+
document.addEventListener('mousedown', handleClickOutside);
63+
return () => document.removeEventListener('mousedown', handleClickOutside);
64+
}
65+
}, [open]);
66+
67+
const handleSelect = useCallback(
68+
(name: string) => {
69+
onChange(name);
70+
setOpen(false);
71+
setSearch('');
72+
},
73+
[onChange]
74+
);
75+
76+
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
77+
setSearch(e.target.value);
78+
if (!open) setOpen(true);
79+
}, [open]);
80+
81+
const handleKeyDown = useCallback(
82+
(e: React.KeyboardEvent) => {
83+
if (e.key === 'Escape') {
84+
setOpen(false);
85+
setSearch('');
86+
}
87+
},
88+
[]
89+
);
90+
91+
// Display label for current value
92+
const selectedCommand = commands.find((c) => c.name === value);
93+
const displayValue = value
94+
? selectedCommand
95+
? `/${selectedCommand.name}`
96+
: `/${value}`
97+
: '';
98+
99+
return (
100+
<div ref={containerRef} className="relative">
101+
{/* Trigger button */}
102+
<button
103+
type="button"
104+
onClick={() => {
105+
setOpen(!open);
106+
if (!open) {
107+
setTimeout(() => inputRef.current?.focus(), 0);
108+
}
109+
}}
110+
className={cn(
111+
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
112+
!value && 'text-muted-foreground',
113+
className
114+
)}
115+
>
116+
<span className={cn('truncate font-mono', !value && 'text-muted-foreground')}>
117+
{displayValue || placeholder || '/command-name'}
118+
</span>
119+
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
120+
</button>
121+
122+
{/* Dropdown */}
123+
{open && (
124+
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-card shadow-md">
125+
{/* Search input */}
126+
<div className="flex items-center border-b border-border px-3">
127+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
128+
<input
129+
ref={inputRef}
130+
value={search}
131+
onChange={handleInputChange}
132+
onKeyDown={handleKeyDown}
133+
placeholder={placeholder || '/command-name'}
134+
className="flex h-9 w-full bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground font-mono"
135+
/>
136+
</div>
137+
138+
{/* Command list */}
139+
<div className="max-h-64 overflow-y-auto p-1">
140+
{isLoading ? (
141+
<div className="py-4 text-center text-sm text-muted-foreground">Loading...</div>
142+
) : totalFiltered === 0 ? (
143+
<div className="py-4 text-center text-sm text-muted-foreground">
144+
No commands found
145+
</div>
146+
) : (
147+
Object.entries(groupedFiltered)
148+
.sort(([a], [b]) => a.localeCompare(b))
149+
.map(([group, cmds]) => (
150+
<div key={group}>
151+
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
152+
{group}
153+
</div>
154+
{cmds.map((cmd) => (
155+
<button
156+
key={cmd.name}
157+
type="button"
158+
onClick={() => handleSelect(cmd.name)}
159+
className={cn(
160+
'flex w-full flex-col items-start rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground',
161+
value === cmd.name && 'bg-accent/50'
162+
)}
163+
>
164+
<span className="font-mono text-foreground">/{cmd.name}</span>
165+
{cmd.description && (
166+
<span className="text-xs text-muted-foreground truncate w-full text-left">
167+
{cmd.description}
168+
</span>
169+
)}
170+
</button>
171+
))}
172+
</div>
173+
))
174+
)}
175+
</div>
176+
</div>
177+
)}
178+
</div>
179+
);
180+
}

ccw/frontend/src/locales/en/common.json

Lines changed: 0 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -267,71 +267,6 @@
267267
"expandAria": "Expand sidebar"
268268
}
269269
},
270-
"liteTasks": {
271-
"title": "Lite Tasks",
272-
"type": {
273-
"plan": "Lite Plan",
274-
"fix": "Lite Fix",
275-
"multiCli": "Multi-CLI Plan"
276-
},
277-
"quickCards": {
278-
"tasks": "Tasks",
279-
"context": "Context"
280-
},
281-
"multiCli": {
282-
"discussion": "Discussion",
283-
"discussionRounds": "Discussion Rounds",
284-
"discussionDescription": "Multi-CLI collaborative planning with iterative analysis and cross-verification",
285-
"summary": "Summary",
286-
"goal": "Goal",
287-
"solution": "Solution",
288-
"implementation": "Implementation",
289-
"feasibility": "Feasibility",
290-
"risk": "Risk",
291-
"planSummary": "Plan Summary"
292-
},
293-
"createdAt": "Created",
294-
"rounds": "rounds",
295-
"tasksCount": "tasks",
296-
"untitled": "Untitled Task",
297-
"discussionTopic": "Discussion Topic",
298-
"contextPanel": {
299-
"loading": "Loading context data...",
300-
"error": "Failed to load context",
301-
"empty": "No context data available",
302-
"explorations": "Explorations",
303-
"explorationsCount": "{count} explorations",
304-
"diagnoses": "Diagnoses",
305-
"diagnosesCount": "{count} diagnoses",
306-
"contextPackage": "Context Package",
307-
"focusPaths": "Focus Paths",
308-
"summary": "Summary",
309-
"taskDescription": "Task Description",
310-
"complexity": "Complexity"
311-
},
312-
"status": {
313-
"completed": "Completed",
314-
"inProgress": "In Progress",
315-
"blocked": "Blocked",
316-
"pending": "Pending"
317-
},
318-
"subtitle": "{count} sessions",
319-
"empty": {
320-
"title": "No {type} sessions",
321-
"message": "No sessions found for this type"
322-
},
323-
"noResults": {
324-
"title": "No results",
325-
"message": "No sessions match your search"
326-
},
327-
"searchPlaceholder": "Search sessions...",
328-
"sortBy": "Sort by",
329-
"sort": {
330-
"date": "Date",
331-
"name": "Name",
332-
"tasks": "Tasks"
333-
}
334-
},
335270
"askQuestion": {
336271
"defaultTitle": "Questions",
337272
"description": "Please answer the following questions",

ccw/frontend/src/locales/en/lite-tasks.json

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,29 @@
44
"type": {
55
"plan": "Lite Plan",
66
"fix": "Lite Fix",
7-
"multiCli": "Multi-CLI"
7+
"multiCli": "Multi-CLI Plan"
88
},
9+
"quickCards": {
10+
"tasks": "Tasks",
11+
"context": "Context"
12+
},
13+
"multiCli": {
14+
"discussion": "Discussion",
15+
"discussionRounds": "Discussion Rounds",
16+
"discussionDescription": "Multi-CLI collaborative planning with iterative analysis and cross-verification",
17+
"summary": "Summary",
18+
"goal": "Goal",
19+
"solution": "Solution",
20+
"implementation": "Implementation",
21+
"feasibility": "Feasibility",
22+
"risk": "Risk",
23+
"planSummary": "Plan Summary"
24+
},
25+
"createdAt": "Created",
926
"rounds": "rounds",
27+
"tasksCount": "tasks",
28+
"untitled": "Untitled Task",
29+
"discussionTopic": "Discussion Topic",
1030
"empty": {
1131
"title": "No {type} sessions",
1232
"message": "Create a new session to get started."
@@ -27,13 +47,10 @@
2747
"focusPaths": "Focus Paths",
2848
"acceptanceCriteria": "Acceptance Criteria",
2949
"dependsOn": "Depends On",
30-
"tasksCount": "tasks",
3150
"emptyDetail": {
3251
"title": "No tasks in this session",
3352
"message": "This session does not contain any tasks yet."
3453
},
35-
"untitled": "Untitled Task",
36-
"discussionTopic": "Discussion Topic",
3754
"notFound": {
3855
"title": "Lite Task Not Found",
3956
"message": "The requested lite task session could not be found."
@@ -43,29 +60,23 @@
4360
"context": "Context"
4461
},
4562
"contextPanel": {
46-
"loading": "Loading context...",
63+
"loading": "Loading context data...",
4764
"error": "Failed to load context",
48-
"empty": "No context data available for this session.",
65+
"empty": "No context data available",
4966
"explorations": "Explorations",
50-
"explorationsCount": "{count} angles",
51-
"contextPackage": "Context Package",
67+
"explorationsCount": "{count} explorations",
5268
"diagnoses": "Diagnoses",
53-
"diagnosesCount": "{count} items",
69+
"diagnosesCount": "{count} diagnoses",
70+
"contextPackage": "Context Package",
5471
"focusPaths": "Focus Paths",
5572
"summary": "Summary",
56-
"complexity": "Complexity",
57-
"taskDescription": "Task Description"
58-
},
59-
"quickCards": {
60-
"tasks": "Tasks",
61-
"explorations": "Explorations",
62-
"context": "Context",
63-
"diagnoses": "Diagnoses"
73+
"taskDescription": "Task Description",
74+
"complexity": "Complexity"
6475
},
6576
"status": {
66-
"completed": "completed",
67-
"inProgress": "in progress",
68-
"blocked": "blocked",
69-
"pending": "pending"
77+
"completed": "Completed",
78+
"inProgress": "In Progress",
79+
"blocked": "Blocked",
80+
"pending": "Pending"
7081
}
7182
}

0 commit comments

Comments
 (0)