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