feat(local-llm): Phase G — projects + multi-model orchestration (G1-G7)
G1: Project CRUD in IndexedDB (already added in Phase F commit)
G2: Project sidebar section with create, pin, delete, and active selection
G3: Project system context injection (via project default model/agent/context)
G4: Cmd+P project switcher modal with keyboard navigation
G5: Chain orchestration — sequential multi-model pipeline with {prev} placeholder
G6: Race orchestration — parallel model competition with timing
G7: Vote orchestration — consensus synthesis from multiple model responses
This commit is contained in:
parent
52f3d16b65
commit
6d98d12f04
@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, X } from 'lucide-react';
|
||||
import type { Orchestration, OrchestrationStep } from '../../lib/types';
|
||||
|
||||
interface OrchestrationEditorProps {
|
||||
orchestration?: Orchestration;
|
||||
models: string[];
|
||||
onSave: (orch: Orchestration) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function OrchestrationEditor({
|
||||
orchestration,
|
||||
models,
|
||||
onSave,
|
||||
onClose,
|
||||
}: OrchestrationEditorProps) {
|
||||
const [name, setName] = useState(orchestration?.name || '');
|
||||
const [mode, setMode] = useState<Orchestration['mode']>(orchestration?.mode || 'chain');
|
||||
const [description, setDescription] = useState(orchestration?.description || '');
|
||||
const [synthesizer, setSynthesizer] = useState(orchestration?.synthesizer || '');
|
||||
const [steps, setSteps] = useState<OrchestrationStep[]>(
|
||||
orchestration?.steps || [{ model: models[0] || '' }]
|
||||
);
|
||||
|
||||
const addStep = () => {
|
||||
setSteps(prev => [...prev, { model: models[0] || '' }]);
|
||||
};
|
||||
|
||||
const removeStep = (idx: number) => {
|
||||
setSteps(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const updateStep = (idx: number, partial: Partial<OrchestrationStep>) => {
|
||||
setSteps(prev => prev.map((s, i) => (i === idx ? { ...s, ...partial } : s)));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim() || steps.length === 0) return;
|
||||
onSave({
|
||||
id: orchestration?.id || crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
mode,
|
||||
steps,
|
||||
synthesizer: mode === 'vote' ? synthesizer : undefined,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-lg rounded-lg border border-white/10 bg-[var(--bg-elevated)] p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{orchestration ? 'Edit Orchestration' : 'New Orchestration'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
|
||||
placeholder="My orchestration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Description</label>
|
||||
<input
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
|
||||
placeholder="What does this orchestration do?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Mode</label>
|
||||
<div className="flex gap-2">
|
||||
{(['chain', 'race', 'vote'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={`rounded border px-3 py-1 text-xs capitalize ${mode === m ? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]/20 text-[var(--accent-primary)]' : 'border-white/10 text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||
{mode === 'chain' && 'Sequential: each step output feeds into the next via {prev}'}
|
||||
{mode === 'race' && 'Parallel: same prompt to all models, pick the best'}
|
||||
{mode === 'vote' &&
|
||||
'Consensus: all models respond, synthesizer produces final answer'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">Steps ({steps.length})</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStep}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-[var(--accent-primary)] hover:underline"
|
||||
>
|
||||
<Plus size={10} /> Add step
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 rounded border border-white/10 bg-[var(--surface-card)] p-2"
|
||||
>
|
||||
<span className="mt-1 text-[10px] text-[var(--text-tertiary)]">#{idx + 1}</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<select
|
||||
value={step.model}
|
||||
onChange={e => updateStep(idx, { model: e.target.value })}
|
||||
className="w-full rounded border border-white/10 bg-[var(--bg-canvas)] px-2 py-1 text-xs text-[var(--text-primary)] outline-none"
|
||||
>
|
||||
<option value="">Select model</option>
|
||||
{models.map(m => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{mode === 'chain' && (
|
||||
<input
|
||||
value={step.systemPrompt || ''}
|
||||
onChange={e => updateStep(idx, { systemPrompt: e.target.value })}
|
||||
placeholder="System prompt (optional, use {prev} for previous output)"
|
||||
className="w-full rounded border border-white/10 bg-[var(--bg-canvas)] px-2 py-1 text-[11px] text-[var(--text-secondary)] outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{steps.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStep(idx)}
|
||||
className="mt-1 text-[var(--text-tertiary)] hover:text-[var(--danger)]"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'vote' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
|
||||
Synthesizer Model
|
||||
</label>
|
||||
<select
|
||||
value={synthesizer}
|
||||
onChange={e => setSynthesizer(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
|
||||
>
|
||||
<option value="">Select synthesizer</option>
|
||||
{models.map(m => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || steps.length === 0}
|
||||
className="rounded bg-[var(--accent-primary)] px-4 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { Orchestration } from '../../lib/types';
|
||||
import { MarkdownResponse } from '../../components/MarkdownResponse';
|
||||
|
||||
interface OrchestrationRunnerProps {
|
||||
orchestration: Orchestration;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
model: string;
|
||||
content: string;
|
||||
durationMs: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
async function streamChat(
|
||||
model: string,
|
||||
messages: { role: string; content: string }[],
|
||||
signal?: AbortSignal
|
||||
): Promise<{ content: string; durationMs: number }> {
|
||||
const start = Date.now();
|
||||
const res = await fetch('/api/ollama/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) throw new Error('Stream failed');
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let content = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(line) as { message?: { content?: string } };
|
||||
if (chunk.message?.content) content += chunk.message.content;
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { content, durationMs: Date.now() - start };
|
||||
}
|
||||
|
||||
export function OrchestrationRunner({ orchestration, onClose }: OrchestrationRunnerProps) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<StepResult[]>([]);
|
||||
const [synthesis, setSynthesis] = useState('');
|
||||
|
||||
const runChain = async (userPrompt: string) => {
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
setSynthesis('');
|
||||
|
||||
let prevOutput = userPrompt;
|
||||
const allResults: StepResult[] = [];
|
||||
|
||||
for (const step of orchestration.steps) {
|
||||
const systemContent = step.systemPrompt
|
||||
? step.systemPrompt.replace(/\{prev\}/g, prevOutput)
|
||||
: undefined;
|
||||
|
||||
const messages: { role: string; content: string }[] = [];
|
||||
if (systemContent) messages.push({ role: 'system', content: systemContent });
|
||||
messages.push({ role: 'user', content: prevOutput });
|
||||
|
||||
const { content, durationMs } = await streamChat(step.model, messages);
|
||||
const stepResult: StepResult = { model: step.model, content, durationMs, done: true };
|
||||
allResults.push(stepResult);
|
||||
setResults([...allResults]);
|
||||
prevOutput = content;
|
||||
}
|
||||
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
const runRace = async (userPrompt: string) => {
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
setSynthesis('');
|
||||
|
||||
const promises = orchestration.steps.map(async step => {
|
||||
const { content, durationMs } = await streamChat(step.model, [
|
||||
{ role: 'user', content: userPrompt },
|
||||
]);
|
||||
return { model: step.model, content, durationMs, done: true } as StepResult;
|
||||
});
|
||||
|
||||
const settled = await Promise.allSettled(promises);
|
||||
const allResults = settled
|
||||
.filter((r): r is PromiseFulfilledResult<StepResult> => r.status === 'fulfilled')
|
||||
.map(r => r.value);
|
||||
|
||||
setResults(allResults);
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
const runVote = async (userPrompt: string) => {
|
||||
setRunning(true);
|
||||
setResults([]);
|
||||
setSynthesis('');
|
||||
|
||||
const promises = orchestration.steps.map(async step => {
|
||||
const { content, durationMs } = await streamChat(step.model, [
|
||||
{ role: 'user', content: userPrompt },
|
||||
]);
|
||||
return { model: step.model, content, durationMs, done: true } as StepResult;
|
||||
});
|
||||
|
||||
const settled = await Promise.allSettled(promises);
|
||||
const allResults = settled
|
||||
.filter((r): r is PromiseFulfilledResult<StepResult> => r.status === 'fulfilled')
|
||||
.map(r => r.value);
|
||||
|
||||
setResults(allResults);
|
||||
|
||||
if (orchestration.synthesizer && allResults.length > 0) {
|
||||
const synthPrompt = `You are a synthesizer. Multiple models responded to the user's question. Analyze their responses, find consensus, and highlight disagreements. Produce a final authoritative answer.
|
||||
|
||||
User question: ${userPrompt}
|
||||
|
||||
${allResults.map((r, i) => `--- Model ${i + 1} (${r.model}) ---\n${r.content}`).join('\n\n')}
|
||||
|
||||
Provide a synthesized consensus response:`;
|
||||
|
||||
const { content } = await streamChat(orchestration.synthesizer, [
|
||||
{ role: 'user', content: synthPrompt },
|
||||
]);
|
||||
setSynthesis(content);
|
||||
}
|
||||
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!prompt.trim() || running) return;
|
||||
if (orchestration.mode === 'chain') await runChain(prompt);
|
||||
else if (orchestration.mode === 'race') await runRace(prompt);
|
||||
else if (orchestration.mode === 'vote') await runVote(prompt);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--bg-canvas)]">
|
||||
<header className="flex items-center justify-between border-b border-white/10 bg-[var(--bg-elevated)] px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">{orchestration.name}</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{orchestration.mode} · {orchestration.steps.length} models
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-3 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{synthesis && (
|
||||
<div className="mb-4 rounded-lg border border-[var(--accent-secondary)]/30 bg-[var(--surface-card)] p-4">
|
||||
<p className="mb-2 text-xs font-medium text-[var(--accent-secondary)]">
|
||||
Synthesized Consensus
|
||||
</p>
|
||||
<MarkdownResponse content={synthesis} isThinkModel={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div
|
||||
className={
|
||||
orchestration.mode === 'race' || orchestration.mode === 'vote'
|
||||
? 'grid gap-3 md:grid-cols-2'
|
||||
: 'space-y-3'
|
||||
}
|
||||
>
|
||||
{results.map((r, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-lg border border-white/10 bg-[var(--surface-card)] p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[var(--text-primary)]">
|
||||
{orchestration.mode === 'chain' ? `Step ${idx + 1}` : ''} {r.model}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-tertiary)]">
|
||||
{(r.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownResponse content={r.content} isThinkModel={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length === 0 && !running && (
|
||||
<p className="text-center text-sm text-[var(--text-tertiary)]">
|
||||
Enter a prompt and run the orchestration.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{running && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-[var(--accent-primary)]">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span className="text-sm">Running {orchestration.mode}...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 bg-[var(--bg-elevated)] p-3">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleRun();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
disabled={running}
|
||||
placeholder="Enter your prompt... (Cmd+Enter to run)"
|
||||
className="min-h-[56px] flex-1 resize-y rounded-md border border-white/10 bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRun()}
|
||||
disabled={running || !prompt.trim()}
|
||||
className="self-end rounded bg-[var(--accent-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FolderPlus, Pin, Trash2 } from 'lucide-react';
|
||||
import type { Project } from '../../lib/types';
|
||||
import { addProject, deleteProject, updateProject } from '../../lib/db';
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
projects: Project[];
|
||||
activeProjectId?: string;
|
||||
onSelect: (projectId: string | undefined) => void;
|
||||
onProjectsChanged: () => void;
|
||||
}
|
||||
|
||||
export function ProjectSidebar({
|
||||
projects,
|
||||
activeProjectId,
|
||||
onSelect,
|
||||
onProjectsChanged,
|
||||
}: ProjectSidebarProps) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
const project: Project = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newName.trim(),
|
||||
icon: '📁',
|
||||
conversationIds: [],
|
||||
pinned: false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await addProject(project);
|
||||
setNewName('');
|
||||
setCreating(false);
|
||||
onProjectsChanged();
|
||||
onSelect(project.id);
|
||||
};
|
||||
|
||||
const handlePin = async (id: string, pinned: boolean) => {
|
||||
await updateProject(id, { pinned: !pinned });
|
||||
onProjectsChanged();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteProject(id);
|
||||
if (activeProjectId === id) onSelect(undefined);
|
||||
onProjectsChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-white/10 px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
Projects
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
title="New project"
|
||||
>
|
||||
<FolderPlus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="mb-1 flex gap-1">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') void handleCreate();
|
||||
if (e.key === 'Escape') setCreating(false);
|
||||
}}
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 rounded border border-white/10 bg-[var(--surface-card)] px-2 py-0.5 text-xs text-[var(--text-primary)] outline-none"
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(undefined)}
|
||||
className={`mb-0.5 block w-full rounded px-2 py-1 text-left text-xs ${!activeProjectId ? 'bg-white/5 text-[var(--accent-primary)]' : 'text-[var(--text-secondary)] hover:bg-white/5'}`}
|
||||
>
|
||||
All Conversations
|
||||
</button>
|
||||
|
||||
{projects.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`group mb-0.5 flex items-center gap-1 rounded px-2 py-1 text-xs ${activeProjectId === p.id ? 'bg-white/5 text-[var(--accent-primary)]' : 'text-[var(--text-secondary)] hover:bg-white/5'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(p.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left"
|
||||
>
|
||||
<span>{p.icon}</span>
|
||||
<span className="truncate">{p.name}</span>
|
||||
</button>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePin(p.id, p.pinned)}
|
||||
className={
|
||||
p.pinned
|
||||
? 'text-[var(--accent-secondary)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
}
|
||||
title={p.pinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<Pin size={10} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(p.id)}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--danger)]"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Project } from '../../lib/types';
|
||||
|
||||
interface ProjectSwitcherProps {
|
||||
open: boolean;
|
||||
projects: Project[];
|
||||
onSelect: (projectId: string | undefined) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProjectSwitcher({ open, projects, onSelect, onClose }: ProjectSwitcherProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const allOption = { id: '', name: 'All Conversations', icon: '📂' };
|
||||
const items = [allOption, ...projects].filter(p =>
|
||||
p.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
onSelect(id || undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(i => Math.min(i + 1, items.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && items[selectedIndex]) {
|
||||
handleSelect(items[selectedIndex].id);
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] bg-black/50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg border border-white/10 bg-[var(--bg-elevated)] shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-white/10 p-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => {
|
||||
setQuery(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Switch project..."
|
||||
className="w-full bg-transparent text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-auto p-1">
|
||||
{items.map((item, idx) => (
|
||||
<button
|
||||
key={item.id || '__all'}
|
||||
type="button"
|
||||
onClick={() => handleSelect(item.id)}
|
||||
className={`flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm ${idx === selectedIndex ? 'bg-white/10 text-[var(--text-primary)]' : 'text-[var(--text-secondary)] hover:bg-white/5'}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-[var(--text-tertiary)]">No projects found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user