feat: allow slug change for members via API + UI

This commit is contained in:
markov 2026-03-13 22:28:58 +01:00
parent 47811ceb91
commit 469a8a9cfc
26 changed files with 3099 additions and 223 deletions

View File

@ -877,3 +877,7 @@ Swagger UI документация
**CORS:** автоматически обрабатывается для разрешённых origins **CORS:** автоматически обрабатывается для разрешённых origins
Этот документ описывает API на основе исходного кода в `/root/projects/team-board/tracker/src/tracker/api/` на дату создания спецификации. Этот документ описывает API на основе исходного кода в `/root/projects/team-board/tracker/src/tracker/api/` на дату создания спецификации.
### Обновление slug (добавлено 2026-03-13)
`PATCH /api/v1/members/{member_id}` теперь принимает поле `slug`.
- Валидация: slug уникален, при конфликте → 409
- Формат: lowercase, только `[a-z0-9_-]`

@ -1 +1 @@
Subproject commit e39c26d3212a7b656bd5629c6feaafcb259f71e3 Subproject commit f7622e13d5da79020848b86cc13d4e21f931ad65

View File

@ -6,6 +6,7 @@ import ProjectPage from "@/pages/ProjectPage";
import CreateProjectPage from "@/pages/CreateProjectPage"; import CreateProjectPage from "@/pages/CreateProjectPage";
import SettingsLayout from "@/pages/settings/SettingsLayout"; import SettingsLayout from "@/pages/settings/SettingsLayout";
import SettingsPage from "@/pages/settings/SettingsPage"; import SettingsPage from "@/pages/settings/SettingsPage";
import LabelsPage from "@/pages/settings/LabelsPage";
import AgentsPage from "@/pages/settings/AgentsPage"; import AgentsPage from "@/pages/settings/AgentsPage";
function App() { function App() {
@ -20,7 +21,7 @@ function App() {
</AuthGuard> </AuthGuard>
} /> } />
<Route path="/projects/:slug" element={ <Route path="/projects/:id" element={
<AuthGuard> <AuthGuard>
<ProjectPage /> <ProjectPage />
</AuthGuard> </AuthGuard>
@ -38,6 +39,7 @@ function App() {
</AuthGuard> </AuthGuard>
}> }>
<Route index element={<SettingsPage />} /> <Route index element={<SettingsPage />} />
<Route path="labels" element={<LabelsPage />} />
<Route path="agents" element={<AgentsPage />} /> <Route path="agents" element={<AgentsPage />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -1,25 +1,312 @@
import type { Member } from "@/lib/api";
import { useState, useEffect } from "react";
import type { Member, Label } from "@/lib/api";
import { updateMember, regenerateToken, revokeToken, getLabels } from "@/lib/api";
interface Props { interface Props {
agent: Member; agent: Member;
onClose: () => void; onClose: () => void;
onUpdated: () => void; onUpdated: (agent: Member) => void;
} }
export default function AgentModal({ agent, onClose, onUpdated: _onUpdated }: Props) { export default function AgentModal({ agent, onClose, onUpdated }: Props) {
const [name, setName] = useState(agent.name);
const [slug, setSlug] = useState(agent.slug);
const [slugError, setSlugError] = useState("");
const [capabilities, setCapabilities] = useState(
agent.agent_config?.capabilities?.join(", ") || ""
);
const [chatListen, setChatListen] = useState(agent.agent_config?.chat_listen || "mentions");
const [taskListen, setTaskListen] = useState(agent.agent_config?.task_listen || "assigned");
const [prompt, setPrompt] = useState(agent.agent_config?.prompt || "");
const [model, setModel] = useState(agent.agent_config?.model || "");
const [saving, setSaving] = useState(false);
const [currentToken, setCurrentToken] = useState<string | null>(agent.token || null);
const [copied, setCopied] = useState(false);
const [confirmRegen, setConfirmRegen] = useState(false);
const [confirmRevoke, setConfirmRevoke] = useState(false);
const [allLabels, setAllLabels] = useState<Label[]>([]);
const [agentLabels, setAgentLabels] = useState<string[]>(agent.agent_config?.labels || []);
useEffect(() => {
getLabels().then(setAllLabels).catch(() => {});
}, []);
const handleSave = async () => {
setSaving(true);
try {
const caps = capabilities.split(",").map((c) => c.trim()).filter(Boolean);
const trimmedSlug = slug.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
if (!trimmedSlug) {
setSlugError("Slug не может быть пустым");
return;
}
const updated = await updateMember(agent.id, {
name: name.trim(),
slug: trimmedSlug !== agent.slug ? trimmedSlug : undefined,
agent_config: {
capabilities: caps,
labels: agentLabels,
chat_listen: chatListen,
task_listen: taskListen,
prompt: prompt.trim() || null,
model: model.trim() || null,
},
});
onUpdated(updated);
onClose();
} catch (e: any) {
if (e?.response?.status === 409) {
setSlugError("Этот slug уже занят");
} else {
console.error(e);
}
} finally {
setSaving(false);
}
};
const handleRegenerate = async () => {
try {
const { token } = await regenerateToken(agent.id);
setCurrentToken(token);
setConfirmRegen(false);
} catch (e) {
console.error(e);
}
};
const handleRevoke = async () => {
try {
await revokeToken(agent.id);
setCurrentToken(null);
setConfirmRevoke(false);
} catch (e) {
console.error(e);
}
};
const copyToken = () => {
if (currentToken) {
navigator.clipboard.writeText(currentToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md"> <div
<h2 className="text-xl font-bold mb-4">Агент: {agent.name}</h2> className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-lg"
<p className="text-[var(--muted)] text-sm mb-4">@{agent.slug}</p> onClick={(e) => e.stopPropagation()}
<p className="text-[var(--muted)] text-sm mb-4">Coming soon...</p> >
<div className="flex gap-2"> <div className="p-5 border-b border-[var(--border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold">{agent.name}</h2>
<div className="text-xs text-[var(--muted)]">@{agent.slug}</div>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
agent.status === "online"
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
}`}
>
{agent.status === "online" ? "online" : "offline"}
</span>
</div>
<div className="p-5 space-y-4">
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Slug</label>
<input
value={slug}
onChange={(e) => { setSlug(e.target.value); setSlugError(""); }}
className={`w-full px-3 py-2 bg-[var(--bg)] border rounded-lg outline-none text-sm ${slugError ? 'border-red-500' : 'border-[var(--border)] focus:border-[var(--accent)]'}`}
placeholder="agent-slug"
/>
{slugError && <div className="text-xs text-red-400 mt-1">{slugError}</div>}
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Capabilities</label>
<input
value={capabilities}
onChange={(e) => setCapabilities(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
placeholder="code, review, deploy"
/>
</div>
{/* Labels */}
{allLabels.length > 0 && (
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Лейблы</label>
<div className="flex flex-wrap gap-1">
{allLabels.map((l) => (
<button
key={l.id}
onClick={() => {
setAgentLabels(prev =>
prev.includes(l.name) ? prev.filter(n => n !== l.name) : [...prev, l.name]
);
}}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors border ${
agentLabels.includes(l.name)
? "border-[var(--accent)] bg-[var(--accent)]/20"
: "border-[var(--border)] bg-white/5 text-[var(--muted)] hover:bg-white/10"
}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: l.color }} />
{l.name}
</button>
))}
</div>
<p className="text-[10px] text-[var(--muted)] mt-1">Для авто-назначения: лейблы задачи лейблы агента</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Chat listen</label>
<select
value={chatListen}
onChange={(e) => setChatListen(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="all">all</option>
<option value="mentions">mentions</option>
<option value="none">none</option>
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Task listen</label>
<select
value={taskListen}
onChange={(e) => setTaskListen(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="all">all</option>
<option value="assigned">assigned</option>
<option value="none">none</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm resize-y min-h-[60px]"
rows={3}
placeholder="Системный промпт агента..."
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Модель</label>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
placeholder="claude-sonnet-4-20250514, gpt-4o, ..."
/>
</div>
{/* Token section */}
<div className="pt-3 border-t border-[var(--border)]">
<div className="text-sm text-[var(--muted)] mb-2">Токен доступа</div>
{currentToken ? (
<div>
<div className="p-3 bg-[var(--bg)] border border-[var(--border)] rounded-lg font-mono text-xs break-all select-all mb-2">
{currentToken}
</div>
<div className="flex items-center gap-2">
<button
onClick={copyToken}
className="px-3 py-1.5 bg-[var(--accent)] text-white rounded text-xs hover:opacity-90"
>
{copied ? "Скопировано!" : "Скопировать"}
</button>
{confirmRegen ? (
<>
<button
onClick={handleRegenerate}
className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded text-xs hover:bg-yellow-500/30"
>
Да, новый
</button>
<button onClick={() => setConfirmRegen(false)} className="text-xs text-[var(--muted)]">
Отмена
</button>
</>
) : (
<button
onClick={() => setConfirmRegen(true)}
className="text-xs text-[var(--muted)] hover:text-[var(--fg)]"
>
Перегенерировать
</button>
)}
{confirmRevoke ? (
<>
<button
onClick={handleRevoke}
className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30"
>
Да, отозвать
</button>
<button onClick={() => setConfirmRevoke(false)} className="text-xs text-[var(--muted)]">
Отмена
</button>
</>
) : (
<button
onClick={() => setConfirmRevoke(true)}
className="text-xs text-red-400/60 hover:text-red-400"
>
Отозвать
</button>
)}
</div>
</div>
) : (
<div className="flex items-center gap-3">
<span className="text-xs text-[var(--muted)]">Токен отозван</span>
<button
onClick={handleRegenerate}
className="text-xs text-[var(--accent)] hover:underline"
>
Сгенерировать новый
</button>
</div>
)}
</div>
</div>
<div className="p-5 border-t border-[var(--border)] flex justify-end gap-2">
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
text-sm hover:bg-white/5 transition-colors"
> >
Закрыть Отмена
</button>
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
>
{saving ? "..." : "Сохранить"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,40 +1,35 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { isAuthenticated } from "@/lib/auth-client"; import { isAuthenticated } from "@/lib/auth-client";
import { wsClient } from "@/lib/ws"; import { wsClient } from "@/lib/ws";
export default function AuthGuard({ children }: { children: React.ReactNode }) { export default function AuthGuard({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const [checked, setChecked] = useState(false); const [checked, setChecked] = useState(false);
const [isAuth, setIsAuth] = useState(false);
useEffect(() => { useEffect(() => {
const authStatus = isAuthenticated(); if (!isAuthenticated()) {
setIsAuth(authStatus); navigate("/login");
} else {
setChecked(true); setChecked(true);
if (authStatus) {
// Connect WebSocket after auth check // Connect WebSocket after auth check
if (!wsClient.connected) { if (!wsClient.connected) {
wsClient.connect(); wsClient.connect();
} }
} }
return () => { return () => {
// Don't disconnect on unmount — singleton stays alive // Don't disconnect on unmount — singleton stays alive
}; };
}, []); }, [navigate]);
if (!checked) { if (!checked) {
return ( return (
<div className="flex h-screen items-center justify-center text-[var(--muted)]"> <div className="flex h-[100dvh] items-center justify-center text-[var(--muted)]">
Загрузка... Загрузка...
</div> </div>
); );
} }
if (!isAuth) {
return <Navigate to="/login" replace />;
}
return <>{children}</>; return <>{children}</>;
} }

View File

@ -1,15 +1,443 @@
interface Props { import { useCallback, useEffect, useRef, useState } from "react";
chatId: string; import type { Message, UploadedFile } from "@/lib/api";
fullscreen?: boolean; import { getMessages, sendMessage, uploadFile, getAttachmentUrl } from "@/lib/api";
import { wsClient } from "@/lib/ws";
import MentionInput from "./MentionInput";
const PAGE_SIZE = 30;
const AUTHOR_ICON: Record<string, string> = {
human: "👤",
agent: "🤖",
system: "⚙️",
};
/** Streaming state for agent that is currently generating */
interface StreamingState {
agentSlug: string;
text: string;
thinking: string;
tools: Array<{ tool: string; status: string }>;
} }
export default function ChatPanel({ chatId, fullscreen }: Props) { interface Props {
chatId: string;
projectId?: string;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function isImageMime(mime: string | null): boolean {
return !!mime && mime.startsWith("image/");
}
export default function ChatPanel({ chatId, projectId }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const mentionMapRef = useRef<Map<string, string>>(new Map()); // slug → member_id
const [sending, setSending] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [pendingFiles, setPendingFiles] = useState<UploadedFile[]>([]);
const [uploading, setUploading] = useState(false);
const [streaming, setStreaming] = useState<StreamingState | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isInitial = useRef(true);
// Load initial messages
useEffect(() => {
if (!chatId) return;
isInitial.current = true;
setHasMore(true);
getMessages({ chat_id: chatId, limit: PAGE_SIZE })
.then((msgs) => {
setMessages(msgs);
setHasMore(msgs.length >= PAGE_SIZE);
setTimeout(() => bottomRef.current?.scrollIntoView(), 50);
})
.catch(() => {});
}, [chatId]);
// WS: new messages
useEffect(() => {
const unsub = wsClient.on("message.new", (msg: Message) => {
if (msg.chat_id !== chatId) return;
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
// Clear streaming when final message arrives
setStreaming(null);
});
return () => { unsub?.(); };
}, [chatId]);
// WS: agent streaming events
useEffect(() => {
const unsubStart = wsClient.on("agent.stream.start", (data: any) => {
if (data.chat_id !== chatId) return;
setStreaming({ agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] });
});
const unsubDelta = wsClient.on("agent.stream.delta", (data: any) => {
if (data.chat_id !== chatId) return;
setStreaming((prev) => {
if (!prev) return { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] };
if (data.block_type === "thinking") {
return { ...prev, thinking: prev.thinking + (data.text || "") };
}
return { ...prev, text: prev.text + (data.text || "") };
});
});
const unsubTool = wsClient.on("agent.stream.tool", (data: any) => {
if (data.chat_id !== chatId) return;
setStreaming((prev) => {
if (!prev) return null;
const tools = [...prev.tools];
const existing = tools.findIndex((t) => t.tool === data.tool);
if (existing >= 0) {
tools[existing] = { tool: data.tool, status: data.status };
} else {
tools.push({ tool: data.tool, status: data.status });
}
return { ...prev, tools };
});
});
const unsubEnd = wsClient.on("agent.stream.end", (data: any) => {
if (data.chat_id !== chatId) return;
// Don't clear immediately — wait for message.new with final content
});
return () => {
unsubStart?.();
unsubDelta?.();
unsubTool?.();
unsubEnd?.();
};
}, [chatId]);
// Auto-scroll on new messages (only if near bottom)
useEffect(() => {
if (isInitial.current) {
isInitial.current = false;
return;
}
const el = scrollRef.current;
if (!el) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
// Load older messages
const loadOlder = useCallback(async () => {
if (loadingOlder || !hasMore || !chatId || messages.length === 0) return;
setLoadingOlder(true);
const el = scrollRef.current;
const prevHeight = el?.scrollHeight || 0;
try {
const older = await getMessages({ chat_id: chatId, limit: PAGE_SIZE, offset: messages.length });
if (older.length < PAGE_SIZE) setHasMore(false);
if (older.length > 0) {
setMessages((prev) => {
const ids = new Set(prev.map((m) => m.id));
return [...older.filter((m) => !ids.has(m.id)), ...prev];
});
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight - prevHeight;
});
}
} catch (e) {
console.error("Failed to load older messages:", e);
} finally {
setLoadingOlder(false);
}
}, [chatId, messages.length, loadingOlder, hasMore]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (el && el.scrollTop < 50 && hasMore && !loadingOlder) loadOlder();
}, [loadOlder, hasMore, loadingOlder]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files?.length) return;
setUploading(true);
try {
const uploaded: UploadedFile[] = [];
for (const file of Array.from(files)) {
const result = await uploadFile(file);
uploaded.push(result);
}
setPendingFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const removePendingFile = (fileId: string) => {
setPendingFiles((prev) => prev.filter((f) => f.file_id !== fileId));
};
const handleSend = async () => {
if ((!input.trim() && pendingFiles.length === 0) || !chatId || sending) return;
const text = input.trim() || (pendingFiles.length > 0 ? `📎 ${pendingFiles.map(f => f.filename).join(", ")}` : "");
const attachments = pendingFiles.map((f) => ({
file_id: f.file_id,
filename: f.filename,
mime_type: f.mime_type,
size: f.size,
storage_name: f.storage_name,
}));
setInput("");
setPendingFiles([]);
mentionMapRef.current.clear();
setSending(true);
try {
// Extract @mentions from text → resolve to member IDs
const mentionMatches = text.match(/@(\w+)/g);
const mentionSlugs = mentionMatches ? [...new Set(mentionMatches.map(m => m.slice(1)))] : [];
const mentions = mentionSlugs
.map(slug => mentionMapRef.current.get(slug))
.filter((id): id is string => !!id);
const msg = await sendMessage({
chat_id: chatId,
content: text,
mentions: mentions.length > 0 ? mentions : undefined,
attachments: attachments.length > 0 ? attachments : undefined,
});
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
} catch (e) {
console.error("Failed to send:", e);
setInput(text);
} finally {
setSending(false);
}
};
const renderContent = (text: string) => {
const parts = text.split(/(@\w+)/g);
return parts.map((part, i) =>
/^@\w+$/.test(part) ? (
<span key={i} className="text-[var(--accent)] font-medium">{part}</span>
) : (
<span key={i}>{part}</span>
)
);
};
const renderAttachments = (attachments: Message["attachments"]) => {
if (!attachments?.length) return null;
return ( return (
<div className="flex items-center justify-center h-full text-[var(--muted)]"> <div className="ml-5 mt-1 flex flex-wrap gap-2">
<div className="text-center"> {attachments.map((att) => {
<h3 className="text-lg mb-2">💬 Chat Panel</h3> const url = getAttachmentUrl(att.id);
<p className="text-sm">Chat ID: {chatId}</p> if (isImageMime(att.mime_type)) {
<p className="text-xs mt-1">Coming soon...</p> return (
<a key={att.id} href={url} target="_blank" rel="noopener noreferrer">
<img
src={url}
alt={att.filename}
className="max-w-[300px] max-h-[200px] rounded border border-[var(--border)] object-cover"
/>
</a>
);
}
return (
<a
key={att.id}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded border border-[var(--border)] bg-[var(--bg)] hover:bg-white/5 text-sm"
>
<span>📎</span>
<span className="truncate max-w-[200px]">{att.filename}</span>
<span className="text-[var(--muted)] text-xs">{formatFileSize(att.size)}</span>
</a>
);
})}
</div>
);
};
const renderMessage = (msg: Message) => {
const bgStyle =
msg.author_type === "system"
? { backgroundColor: "rgba(161, 120, 0, 0.2)", borderRadius: 6, padding: "4px 8px" }
: msg.author_type === "agent"
? { backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }
: undefined;
return (
<div key={msg.id} className="text-sm" style={bgStyle}>
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
<span className="font-medium">
{msg.author_type === "system"
? (msg.actor ? `Система (${msg.actor.name})` : "Система")
: (msg.author?.name || msg.author?.slug || "Unknown")}
</span>
<span>·</span>
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
{msg.thinking && (
<details className="ml-5 mb-1">
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
💭 Размышления
</summary>
<div className="mt-1 text-xs text-[var(--muted)] whitespace-pre-wrap bg-white/5 rounded p-2 max-h-[200px] overflow-y-auto">
{msg.thinking}
</div>
</details>
)}
{msg.tool_log && msg.tool_log.length > 0 && (
<details className="ml-5 mb-1">
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
🔧 Инструменты ({msg.tool_log.length})
</summary>
<div className="mt-1 text-xs text-[var(--muted)] bg-white/5 rounded p-2 max-h-[300px] overflow-y-auto space-y-1">
{msg.tool_log.map((t, i) => (
<div key={i} className="border-b border-white/10 pb-1 last:border-0">
<span className="font-mono text-blue-400">{t.name}</span>
{t.result && (
<div className="ml-3 text-[var(--muted)] truncate max-w-[500px]" title={t.result}>
{t.result.slice(0, 150)}{t.result.length > 150 ? '…' : ''}
</div>
)}
</div>
))}
</div>
</details>
)}
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
{renderAttachments(msg.attachments)}
</div>
);
};
return (
<div className="flex flex-col h-full">
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-4 space-y-3"
>
{loadingOlder && (
<div className="text-center text-xs text-[var(--muted)] py-2">Загрузка...</div>
)}
{!hasMore && messages.length > 0 && (
<div className="text-center text-xs text-[var(--muted)] py-2"> начало истории </div>
)}
{messages.map(renderMessage)}
{messages.length === 0 && !streaming && (
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
)}
{/* Streaming indicator */}
{streaming && (
<div className="text-sm" style={{ backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }}>
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>🤖</span>
<span className="font-medium">{streaming.agentSlug}</span>
<span className="animate-pulse"></span>
</div>
{streaming.thinking && (
<details className="ml-5 mb-1" open>
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
💭 Размышления
</summary>
<div className="mt-1 text-xs text-[var(--muted)] whitespace-pre-wrap bg-white/5 rounded p-2 max-h-[200px] overflow-y-auto">
{streaming.thinking}
</div>
</details>
)}
{streaming.tools.length > 0 && (
<div className="ml-5 mb-1 text-xs text-[var(--muted)]">
{streaming.tools.map((t, i) => (
<span key={i} className="mr-2">
{t.status === "running" ? "🔧" : "✅"} {t.tool}
</span>
))}
</div>
)}
{streaming.text && (
<div className="ml-5 whitespace-pre-wrap">{streaming.text}</div>
)}
{!streaming.text && streaming.tools.length === 0 && !streaming.thinking && (
<div className="ml-5 text-[var(--muted)] italic">Думает...</div>
)}
</div>
)}
<div ref={bottomRef} />
</div>
{/* Pending files preview */}
{pendingFiles.length > 0 && (
<div className="px-6 py-2 border-t border-[var(--border)] flex flex-wrap gap-2">
{pendingFiles.map((f) => (
<div
key={f.file_id}
className="flex items-center gap-1 px-2 py-1 rounded bg-[var(--accent)]/10 text-xs"
>
<span>📎</span>
<span className="truncate max-w-[150px]">{f.filename}</span>
<span className="text-[var(--muted)]">{formatFileSize(f.size)}</span>
<button
onClick={() => removePendingFile(f.file_id)}
className="ml-1 text-[var(--muted)] hover:text-red-400"
>
</button>
</div>
))}
</div>
)}
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2 items-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-2 py-2 text-[var(--muted)] hover:text-[var(--fg)] transition-colors shrink-0 disabled:opacity-50"
title="Прикрепить файл"
>
{uploading ? "⏳" : "📎"}
</button>
<MentionInput
projectId={projectId || ""}
value={input}
onChange={setInput}
onSubmit={handleSend}
onMentionInsert={(id, slug) => mentionMapRef.current.set(slug, id)}
placeholder="Сообщение... (@mention)"
disabled={sending}
/>
<button
onClick={handleSend}
disabled={sending}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 shrink-0"
>
</button>
</div> </div>
</div> </div>
); );

View File

@ -1,19 +1,91 @@
import { useState } from "react";
import type { MemberCreateResponse } from "@/lib/api";
import { createMember } from "@/lib/api";
interface Props { interface Props {
onCreated: () => void; onCreated: (member: MemberCreateResponse) => void;
onClose: () => void; onClose: () => void;
} }
export default function CreateAgentModal({ onCreated, onClose }: Props) { export default function CreateAgentModal({ onCreated, onClose }: Props) {
const [name, setName] = useState("");
const [capabilities, setCapabilities] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const slug = name
.toLowerCase()
.replace(/[^a-zа-яё0-9\s-]/gi, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 50);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !slug) return;
setLoading(true);
setError("");
try {
const caps = capabilities
.split(",")
.map((c) => c.trim())
.filter(Boolean);
const member = await createMember({
name: name.trim(),
slug,
type: "agent",
agent_config: {
capabilities: caps,
chat_listen: "mentions",
task_listen: "assigned",
},
});
if (member.token) {
setToken(member.token);
}
onCreated(member);
} catch {
setError("Ошибка создания агента");
} finally {
setLoading(false);
}
};
const copyToken = () => {
if (token) {
navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (token) {
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md"> <div
<h2 className="text-xl font-bold mb-4">Создать агента</h2> onClick={(e) => e.stopPropagation()}
<p className="text-[var(--muted)] text-sm mb-4">Coming soon...</p> className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
<div className="flex gap-2"> >
<h2 className="text-lg font-bold mb-4">Агент создан</h2>
<div className="mb-3 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-400 text-sm">
Токен показывается только один раз. Сохраните его сейчас!
</div>
<div className="mb-4 p-3 bg-[var(--bg)] border border-[var(--border)] rounded-lg font-mono text-xs break-all select-all">
{token}
</div>
<div className="flex gap-2 justify-end">
<button
onClick={copyToken}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90"
>
{copied ? "Скопировано!" : "Скопировать"}
</button>
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
text-sm hover:bg-white/5 transition-colors"
> >
Закрыть Закрыть
</button> </button>
@ -21,4 +93,61 @@ export default function CreateAgentModal({ onCreated, onClose }: Props) {
</div> </div>
</div> </div>
); );
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<form
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
>
<h2 className="text-lg font-bold mb-4">Новый агент</h2>
{error && (
<div className="mb-3 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
{error}
</div>
)}
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full mb-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
outline-none focus:border-[var(--accent)] text-sm"
placeholder="Code Assistant"
/>
{slug && <div className="text-xs text-[var(--muted)] mb-3">Slug: {slug}</div>}
<label className="block text-sm text-[var(--muted)] mb-1">Capabilities</label>
<input
value={capabilities}
onChange={(e) => setCapabilities(e.target.value)}
className="w-full mb-4 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
outline-none focus:border-[var(--accent)] text-sm"
placeholder="code, review, deploy"
/>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
Отмена
</button>
<button
type="submit"
disabled={loading || !name.trim()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm
hover:opacity-90 disabled:opacity-50"
>
{loading ? "..." : "Создать"}
</button>
</div>
</form>
</div>
);
} }

View File

@ -1,5 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { createProject, type Project } from "@/lib/api"; import type { Project } from "@/lib/api";
import { createProject } from "@/lib/api";
interface Props { interface Props {
onCreated: (project: Project) => void; onCreated: (project: Project) => void;
@ -8,107 +10,90 @@ interface Props {
export default function CreateProjectModal({ onCreated, onClose }: Props) { export default function CreateProjectModal({ onCreated, onClose }: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const slug = name
.toLowerCase()
.replace(/[^a-zа-яё0-9\s-]/gi, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 50);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); if (!name.trim() || !slug) return;
setLoading(true); setLoading(true);
setError("");
try { try {
const project = await createProject({ name, slug, description: description || undefined }); const project = await createProject({
name: name.trim(),
slug,
description: description.trim() || undefined,
});
onCreated(project); onCreated(project);
} catch (err: any) { } catch (err) {
setError(err.message || "Ошибка создания проекта"); setError("Ошибка создания проекта");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const generateSlug = () => {
if (name && !slug) {
const generated = name.toLowerCase()
.replace(/[^a-zа-я0-9\s]/g, '')
.replace(/\s+/g, '-');
setSlug(generated);
}
};
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md"> <form
<h2 className="text-xl font-bold mb-4">Создать проект</h2> onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
>
<h2 className="text-lg font-bold mb-4">Новый проект</h2>
{error && ( {error && (
<div className="mb-4 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm"> <div className="mb-3 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
{error} {error}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<div>
<label className="block text-sm mb-1">Название</label>
<input <input
type="text" autoFocus
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onBlur={generateSlug} className="w-full mb-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
outline-none focus:border-[var(--accent)] text-sm" outline-none focus:border-[var(--accent)] text-sm"
placeholder="Мой проект" placeholder="Мой проект"
required
/> />
</div> {slug && <div className="text-xs text-[var(--muted)] mb-3">Slug: {slug}</div>}
<div> <label className="block text-sm text-[var(--muted)] mb-1">Описание</label>
<label className="block text-sm mb-1">Slug (URL)</label>
<input
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
outline-none focus:border-[var(--accent)] text-sm font-mono"
placeholder="my-project"
pattern="[a-z0-9-]+"
required
/>
</div>
<div>
<label className="block text-sm mb-1">Описание (опционально)</label>
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded className="w-full mb-4 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
outline-none focus:border-[var(--accent)] text-sm resize-none" outline-none focus:border-[var(--accent)] text-sm resize-none"
rows={3} rows={3}
placeholder="Краткое описание проекта" placeholder="Необязательно"
/> />
</div>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 justify-end">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
text-sm hover:bg-white/5 transition-colors"
> >
Отмена Отмена
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading || !name || !slug} disabled={loading || !name.trim()}
className="flex-1 py-2 bg-[var(--accent)] text-white rounded text-sm className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm
hover:opacity-90 transition-opacity disabled:opacity-50" hover:opacity-90 disabled:opacity-50"
> >
{loading ? "Создаём..." : "Создать"} {loading ? "..." : "Создать"}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div>
); );
} }

View File

@ -0,0 +1,244 @@
import { useState, useEffect } from "react";
import type { Task, Member, Label } from "@/lib/api";
import { createTask, getMembers, getLabels } from "@/lib/api";
const STATUSES = [
{ key: "backlog", label: "Backlog", color: "#737373" },
{ key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "in_review", label: "Review", color: "#a855f7" },
{ key: "done", label: "Done", color: "#22c55e" },
];
const PRIORITIES = [
{ key: "low", label: "Low", color: "#737373" },
{ key: "medium", label: "Medium", color: "#3b82f6" },
{ key: "high", label: "High", color: "#f59e0b" },
{ key: "critical", label: "Critical", color: "#ef4444" },
];
const TYPES = [
{ key: "task", label: "Задача" },
{ key: "bug", label: "Баг" },
{ key: "feature", label: "Фича" },
];
interface Props {
projectId: string;
initialStatus: string;
parentId?: string;
parentKey?: string;
onClose: () => void;
onCreated: (task: Task) => void;
}
export default function CreateTaskModal({ projectId, initialStatus, parentId, parentKey, onClose, onCreated }: Props) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState(initialStatus || "backlog");
const [priority, setPriority] = useState("medium");
const [type, setType] = useState("task");
const [assigneeId, setAssigneeId] = useState("");
const [reviewerId, setReviewerId] = useState("");
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [members, setMembers] = useState<Member[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
useEffect(() => {
getMembers().then(setMembers).catch(() => {});
getLabels().then(setLabels).catch(() => {});
}, []);
const handleSubmit = async () => {
if (!title.trim()) return;
setSaving(true);
setError("");
try {
const task = await createTask(projectId, {
title: title.trim(),
description: description.trim() || undefined,
status,
priority,
type,
parent_id: parentId,
assignee_id: assigneeId || undefined,
reviewer_id: reviewerId || undefined,
labels: selectedLabels,
});
onCreated(task);
onClose();
} catch (e: any) {
setError(e.message || "Ошибка");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" onClick={onClose}>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold mb-4">
{parentKey ? `Подзадача для ${parentKey}` : "Новая задача"}
</h3>
<div className="space-y-4">
{/* Title */}
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Название</label>
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
placeholder="Название задачи..."
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Description */}
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Описание (опционально)..."
rows={3}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] resize-y"
/>
</div>
{/* Type */}
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Тип</label>
<div className="flex gap-1">
{TYPES.map((t) => (
<button
key={t.key}
onClick={() => setType(t.key)}
className={`px-2 py-1 rounded text-xs transition-colors
${type === t.key ? "bg-[var(--accent)] text-white" : "bg-white/5 text-[var(--muted)] hover:bg-white/10"}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Status + Priority row */}
<div className="flex gap-4">
<div className="flex-1">
<label className="text-xs text-[var(--muted)] mb-1 block">Статус</label>
<div className="flex flex-wrap gap-1">
{STATUSES.map((s) => (
<button
key={s.key}
onClick={() => setStatus(s.key)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${status === s.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
{s.label}
</button>
))}
</div>
</div>
</div>
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Приоритет</label>
<div className="flex gap-1">
{PRIORITIES.map((p) => (
<button
key={p.key}
onClick={() => setPriority(p.key)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${priority === p.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
{p.label}
</button>
))}
</div>
</div>
{/* Assignee + Reviewer */}
<div className="flex gap-4">
<div className="flex-1">
<label className="text-xs text-[var(--muted)] mb-1 block">Исполнитель</label>
<select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
className="w-full px-2 py-1.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none"
>
<option value="">Не назначен</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.name} ({m.slug})</option>
))}
</select>
</div>
<div className="flex-1">
<label className="text-xs text-[var(--muted)] mb-1 block">Ревьюер</label>
<select
value={reviewerId}
onChange={(e) => setReviewerId(e.target.value)}
className="w-full px-2 py-1.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none"
>
<option value="">Не назначен</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.name} ({m.slug})</option>
))}
</select>
</div>
</div>
{/* Labels */}
{labels.length > 0 && (
<div>
<label className="text-xs text-[var(--muted)] mb-1 block">Лейблы</label>
<div className="flex flex-wrap gap-1">
{labels.map((l) => (
<button
key={l.id}
onClick={() => {
setSelectedLabels(prev =>
prev.includes(l.name) ? prev.filter(n => n !== l.name) : [...prev, l.name]
);
}}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors border ${
selectedLabels.includes(l.name)
? "border-[var(--accent)] bg-[var(--accent)]/20"
: "border-[var(--border)] bg-white/5 text-[var(--muted)] hover:bg-white/10"
}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: l.color }} />
{l.name}
</button>
))}
</div>
</div>
)}
{error && <div className="text-xs text-red-400">{error}</div>}
<div className="flex gap-2 justify-end pt-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--muted)]">Отмена</button>
<button
onClick={handleSubmit}
disabled={!title.trim() || saving}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm disabled:opacity-50"
>
{saving ? "..." : "Создать"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,16 +1,242 @@
import { useEffect, useState } from "react";
import type { Task } from "@/lib/api";
import { getTasks, updateTask } from "@/lib/api";
import TaskModal from "@/components/TaskModal";
import CreateTaskModal from "@/components/CreateTaskModal";
import { wsClient } from "@/lib/ws";
const COLUMNS = [
{ key: "backlog", label: "Backlog", color: "#737373" },
{ key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "in_review", label: "Review", color: "#a855f7" },
{ key: "done", label: "Done", color: "#22c55e" },
];
const PRIORITY_COLORS: Record<string, string> = {
critical: "#ef4444",
high: "#f59e0b",
medium: "#3b82f6",
low: "#737373",
};
interface Props { interface Props {
projectId: string; projectId: string;
projectSlug: string; projectSlug: string;
} }
export default function KanbanBoard({ projectId, projectSlug }: Props) { export default function KanbanBoard({ projectId, projectSlug }: Props) {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [draggedTask, setDraggedTask] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [createInStatus, setCreateInStatus] = useState<string | null>(null);
const prefix = projectSlug.slice(0, 2).toUpperCase();
const loadTasks = async () => {
try {
const data = await getTasks(projectId);
setTasks(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
// Subscribe to WebSocket events for real-time updates
const unsubscribeCreated = wsClient.on("task.created", (data: Task) => {
if (data.project_id === projectId) {
setTasks((prev) => prev.some((t) => t.id === data.id) ? prev : [...prev, data]);
}
});
const unsubscribeUpdated = wsClient.on("task.updated", (data: Task) => {
if (data.project_id === projectId) {
setTasks((prev) => prev.map((t) => t.id === data.id ? data : t));
}
});
const unsubscribeAssigned = wsClient.on("task.assigned", (data: Task) => {
if (data.project_id === projectId) {
setTasks((prev) => prev.map((t) => t.id === data.id ? data : t));
}
});
return () => {
unsubscribeCreated?.();
unsubscribeUpdated?.();
unsubscribeAssigned?.();
};
}, [projectId]);
const handleDrop = async (status: string) => {
if (!draggedTask) return;
const task = tasks.find((t) => t.id === draggedTask);
if (!task || task.status === status) return;
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
setDraggedTask(null);
try { await updateTask(draggedTask, { status }); } catch { loadTasks(); }
};
const handleMoveTask = async (taskId: string, newStatus: string) => {
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
try { await updateTask(taskId, { status: newStatus }); } catch { loadTasks(); }
};
if (loading) {
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
}
const TaskCard = ({ task, showMoveButtons, colIndex }: { task: Task; showMoveButtons?: boolean; colIndex?: number }) => (
<div
key={task.id}
draggable
onDragStart={() => setDraggedTask(task.id)}
onClick={() => setSelectedTask(task)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-pointer
hover:border-[var(--accent)] transition-colors"
>
<div className="flex items-center gap-1.5 mb-1">
<div className="w-2 h-2 rounded-full shrink-0"
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
title={task.priority} />
<span className="text-xs text-[var(--muted)]">
{task.parent_key && <span>{task.parent_key} / </span>}
{prefix}-{task.number}
</span>
{task.assignee && (
<span className="text-xs text-[var(--muted)] ml-auto"> {task.assignee.slug}</span>
)}
</div>
<div className="text-sm ml-3.5">{task.title}</div>
{task.labels && task.labels.length > 0 && (
<div className="flex flex-wrap gap-1 ml-3.5 mt-1">
{task.labels.map((label: string) => (
<span key={label} className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--accent)]/20 text-[var(--accent)]">
{label}
</span>
))}
</div>
)}
{showMoveButtons && colIndex !== undefined && (
<div className="flex gap-1 mt-2 ml-3.5">
{colIndex > 0 && (
<button
onClick={(e) => { e.stopPropagation(); handleMoveTask(task.id, COLUMNS[colIndex - 1].key); }}
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)] hover:text-[var(--fg)]"
> {COLUMNS[colIndex - 1].label}</button>
)}
{colIndex < COLUMNS.length - 1 && (
<button
onClick={(e) => { e.stopPropagation(); handleMoveTask(task.id, COLUMNS[colIndex + 1].key); }}
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)] hover:text-[var(--fg)]"
>{COLUMNS[colIndex + 1].label} </button>
)}
</div>
)}
</div>
);
return ( return (
<div className="flex items-center justify-center h-full text-[var(--muted)]"> <div className="h-full flex flex-col">
<div className="text-center"> {/* Mobile column tabs */}
<h3 className="text-lg mb-2">📋 Kanban Board</h3> <div className="md:hidden flex overflow-x-auto border-b border-[var(--border)] px-2 py-1 gap-1 shrink-0">
<p className="text-sm">Project: {projectSlug}</p> {COLUMNS.map((col) => {
<p className="text-xs mt-1">Coming soon...</p> const count = tasks.filter((t) => t.status === col.key).length;
return (
<button key={col.key} onClick={() => setActiveColumn(col.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs whitespace-nowrap shrink-0 transition-colors
${(activeColumn || "backlog") === col.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)]"}`}>
<span className="w-2 h-2 rounded-full" style={{ background: col.color }} />
{col.label}
{count > 0 && <span className="text-[var(--muted)]">{count}</span>}
</button>
);
})}
</div>
{/* Mobile: single column */}
<div className="md:hidden flex-1 overflow-y-auto p-3">
{(() => {
const col = COLUMNS.find((c) => c.key === (activeColumn || "backlog"))!;
const colTasks = tasks.filter((t) => t.status === col.key);
const colIndex = COLUMNS.findIndex((c) => c.key === col.key);
return (
<div className="flex flex-col gap-2">
{colTasks.map((task) => (
<TaskCard key={task.id} task={task} showMoveButtons colIndex={colIndex} />
))}
<button
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] py-2"
onClick={() => setCreateInStatus(col.key)}
>+ Добавить задачу</button>
</div>
);
})()}
</div>
{/* Desktop: horizontal kanban */}
<div className="hidden md:flex gap-4 overflow-x-auto p-4 flex-1">
{COLUMNS.map((col) => {
const colTasks = tasks.filter((t) => t.status === col.key);
return (
<div key={col.key} className="flex flex-col min-w-[280px] w-[280px] shrink-0"
onDragOver={(e) => e.preventDefault()} onDrop={() => handleDrop(col.key)}>
<div className="flex items-center gap-2 mb-3 px-1">
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
<span className="font-semibold text-sm">{col.label}</span>
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
</div>
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
{colTasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
<button
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
onClick={() => setCreateInStatus(col.key)}
>+ Добавить</button>
</div> </div>
</div> </div>
); );
})}
</div>
{/* Task modal */}
{selectedTask && (
<TaskModal
task={selectedTask}
projectId={projectId}
projectSlug={projectSlug}
onClose={() => setSelectedTask(null)}
onUpdated={(updated) => {
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setSelectedTask(updated);
}}
onDeleted={(id) => {
setTasks((prev) => prev.filter((t) => t.id !== id));
setSelectedTask(null);
}}
onOpenTask={(taskId) => {
const t = tasks.find(t => t.id === taskId);
if (t) setSelectedTask(t);
}}
/>
)}
{/* Create task modal */}
{createInStatus && (
<CreateTaskModal
projectId={projectId}
initialStatus={createInStatus}
onClose={() => setCreateInStatus(null)}
onCreated={(task) => setTasks((prev) => prev.some((t) => t.id === task.id) ? prev : [...prev, task])}
/>
)}
</div>
);
} }

View File

@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ProjectMember } from "@/lib/api";
import { getProjectMembers } from "@/lib/api";
interface Props {
projectId: string;
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onMentionInsert?: (memberId: string, slug: string) => void;
placeholder?: string;
disabled?: boolean;
}
export default function MentionInput({ projectId, value, onChange, onSubmit, onMentionInsert, placeholder, disabled }: Props) {
const [members, setMembers] = useState<ProjectMember[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [filter, setFilter] = useState("");
const [selectedIdx, setSelectedIdx] = useState(0);
const [mentionStart, setMentionStart] = useState(-1);
const inputRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Load members once
useEffect(() => {
if (!projectId) return;
getProjectMembers(projectId).then(setMembers).catch(() => {});
}, [projectId]);
const filtered = members.filter((m) =>
m.name.toLowerCase().includes(filter.toLowerCase()) ||
m.slug.toLowerCase().includes(filter.toLowerCase())
);
const insertMention = useCallback((member: ProjectMember) => {
if (mentionStart < 0) return;
const before = value.slice(0, mentionStart);
const after = value.slice(inputRef.current?.selectionStart ?? value.length);
const newValue = `${before}@${member.slug} ${after}`;
onChange(newValue);
onMentionInsert?.(member.id, member.slug);
setShowDropdown(false);
setMentionStart(-1);
// Focus and set cursor after mention
requestAnimationFrame(() => {
const el = inputRef.current;
if (el) {
const pos = before.length + member.slug.length + 2; // @slug + space
el.focus();
el.setSelectionRange(pos, pos);
}
});
}, [value, mentionStart, onChange]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newVal = e.target.value;
onChange(newVal);
const cursorPos = e.target.selectionStart;
// Find @ before cursor
const textBeforeCursor = newVal.slice(0, cursorPos);
const lastAt = textBeforeCursor.lastIndexOf("@");
if (lastAt >= 0) {
// Check: @ must be at start or after whitespace
const charBefore = lastAt > 0 ? textBeforeCursor[lastAt - 1] : " ";
if (/\s/.test(charBefore) || lastAt === 0) {
const query = textBeforeCursor.slice(lastAt + 1);
// No spaces in mention query
if (!/\s/.test(query)) {
setMentionStart(lastAt);
setFilter(query);
setShowDropdown(true);
setSelectedIdx(0);
return;
}
}
}
setShowDropdown(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showDropdown && filtered.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIdx((i) => (i + 1) % filtered.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIdx((i) => (i - 1 + filtered.length) % filtered.length);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
insertMention(filtered[selectedIdx]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setShowDropdown(false);
return;
}
}
if (e.key === "Enter" && !e.shiftKey && !showDropdown) {
e.preventDefault();
onSubmit();
}
};
const ICON: Record<string, string> = { human: "👤", agent: "🤖" };
return (
<div className="relative flex-1">
<textarea
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)] resize-none"
style={{ minHeight: "38px", maxHeight: "120px" }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}}
/>
{showDropdown && filtered.length > 0 && (
<div
ref={dropdownRef}
className="absolute bottom-full left-0 mb-1 w-64 bg-[var(--surface)] border border-[var(--border)] rounded-lg shadow-lg overflow-hidden z-50"
>
{filtered.map((m, i) => (
<button
key={m.id}
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-white/10 ${
i === selectedIdx ? "bg-white/10" : ""
}`}
onMouseDown={(e) => {
e.preventDefault(); // prevent blur
insertMention(m);
}}
>
<span>{ICON[m.type] || "👤"}</span>
<span className="font-medium">{m.name}</span>
<span className="text-[var(--muted)]">@{m.slug}</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@ -1,17 +1,268 @@
import type { Project } from "@/lib/api"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Project, ProjectFile } from "@/lib/api";
import {
getProjectFiles,
uploadProjectFile,
getProjectFileUrl,
updateProjectFile,
deleteProjectFile,
} from "@/lib/api";
interface Props { interface Props {
project: Project; project: Project;
} }
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function fileIcon(mime: string | null, filename: string): string {
if (mime?.startsWith("image/")) return "🖼️";
if (mime?.startsWith("video/")) return "🎬";
if (mime?.startsWith("audio/")) return "🎵";
if (mime === "application/pdf") return "📕";
const ext = filename.split(".").pop()?.toLowerCase();
if (["md", "txt", "rst"].includes(ext || "")) return "📝";
if (["json", "yaml", "yml", "toml", "xml"].includes(ext || "")) return "⚙️";
if (["py", "ts", "js", "rs", "go", "java", "c", "cpp", "h"].includes(ext || "")) return "💻";
if (["zip", "tar", "gz", "rar", "7z"].includes(ext || "")) return "📦";
if (["doc", "docx", "odt"].includes(ext || "")) return "📝";
if (["xls", "xlsx", "csv"].includes(ext || "")) return "📊";
return "📄";
}
export default function ProjectFiles({ project }: Props) { export default function ProjectFiles({ project }: Props) {
const [files, setFiles] = useState<ProjectFile[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editDesc, setEditDesc] = useState("");
const [uploadDesc, setUploadDesc] = useState("");
const [showUploadDesc, setShowUploadDesc] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadFiles = useCallback(async () => {
try {
const data = await getProjectFiles(project.id, search || undefined);
setFiles(data);
} catch (e) {
console.error("Failed to load files:", e);
} finally {
setLoading(false);
}
}, [project.id, search]);
// Debounced search
useEffect(() => {
setLoading(true);
const timer = setTimeout(loadFiles, search ? 300 : 0);
return () => clearTimeout(timer);
}, [loadFiles, search]);
const handleUpload = async (fileList: FileList) => {
setUploading(true);
try {
for (const file of Array.from(fileList)) {
await uploadProjectFile(project.id, file, uploadDesc || undefined);
}
setUploadDesc("");
setShowUploadDesc(false);
await loadFiles();
} catch (e) {
console.error("Upload failed:", e);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleDelete = async (f: ProjectFile) => {
if (!confirm(`Удалить ${f.filename}?`)) return;
try {
await deleteProjectFile(project.id, f.id);
setFiles((prev) => prev.filter((x) => x.id !== f.id));
} catch (e) {
console.error("Delete failed:", e);
}
};
const handleSaveDesc = async (f: ProjectFile) => {
try {
const updated = await updateProjectFile(project.id, f.id, { description: editDesc });
setFiles((prev) => prev.map((x) => (x.id === f.id ? updated : x)));
setEditingId(null);
} catch (e) {
console.error("Update failed:", e);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files);
};
return ( return (
<div className="flex items-center justify-center h-full text-[var(--muted)]"> <div
<div className="text-center"> className={`flex flex-col h-full ${dragOver ? "ring-2 ring-[var(--accent)] ring-inset" : ""}`}
<h3 className="text-lg mb-2">📁 Project Files</h3> onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
<p className="text-sm">Project: {project.name}</p> onDragLeave={() => setDragOver(false)}
<p className="text-xs mt-1">Coming soon...</p> onDrop={handleDrop}
>
{/* Header */}
<div className="px-6 py-4 border-b border-[var(--border)] flex flex-col gap-3">
<div className="flex items-center gap-3">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="🔍 Поиск по имени..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => e.target.files && handleUpload(e.target.files)}
/>
<button
onClick={() => setShowUploadDesc(!showUploadDesc)}
className={`text-xs transition-colors ${showUploadDesc ? "text-[var(--accent)]" : "text-[var(--muted)] hover:text-[var(--fg)]"}`}
title="Добавить описание при загрузке"
>
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 shrink-0"
>
{uploading ? "⏳ Загрузка..." : "📎 Загрузить"}
</button>
</div>
{showUploadDesc && (
<input
value={uploadDesc}
onChange={(e) => setUploadDesc(e.target.value)}
placeholder="Описание для загружаемых файлов (опционально)"
className="bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
/>
)}
</div>
{/* File list */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading && (
<div className="text-center text-sm text-[var(--muted)] py-8">Загрузка...</div>
)}
{!loading && files.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-[var(--muted)]">
<div className="text-4xl mb-4">📁</div>
<div className="text-sm">Нет файлов</div>
<div className="text-xs mt-1">Перетащите файлы сюда или нажмите «Загрузить»</div>
</div>
)}
{!loading && files.length > 0 && (
<div className="space-y-1">
{files.map((f) => {
const url = getProjectFileUrl(project.id, f.id);
const isImage = f.mime_type?.startsWith("image/");
return (
<div
key={f.id}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 group"
>
{/* Icon / thumbnail */}
<div className="shrink-0 w-8 text-center">
{isImage ? (
<img src={url} alt="" className="w-8 h-8 rounded object-cover" loading="lazy" />
) : (
<span className="text-lg">{fileIcon(f.mime_type, f.filename)}</span>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-[var(--fg)] hover:text-[var(--accent)] truncate"
>
{f.filename}
</a>
<span className="text-xs text-[var(--muted)]">{formatSize(f.size)}</span>
</div>
{editingId === f.id ? (
<div className="flex items-center gap-1 mt-1">
<input
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveDesc(f);
if (e.key === "Escape") setEditingId(null);
}}
placeholder="Описание файла..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-0.5 text-xs outline-none focus:border-[var(--accent)]"
autoFocus
/>
<button onClick={() => handleSaveDesc(f)} className="text-xs text-green-400 hover:text-green-300"></button>
<button onClick={() => setEditingId(null)} className="text-xs text-[var(--muted)] hover:text-[var(--fg)]"></button>
</div>
) : (
f.description && (
<div className="text-xs text-[var(--muted)] truncate mt-0.5">{f.description}</div>
)
)}
</div>
{/* Uploader */}
<div className="text-xs text-[var(--muted)] hidden md:block shrink-0">
{f.uploaded_by?.name}
</div>
{/* Date */}
<div className="text-xs text-[var(--muted)] hidden md:block shrink-0">
{new Date(f.created_at).toLocaleDateString("ru-RU")}
</div>
{/* Actions — visible on hover */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => { setEditingId(f.id); setEditDesc(f.description || ""); }}
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] p-1"
title="Описание"
>
</button>
<button
onClick={() => handleDelete(f)}
className="text-xs text-[var(--muted)] hover:text-red-400 p-1"
title="Удалить"
>
🗑
</button>
</div> </div>
</div> </div>
); );
})}
</div>
)}
</div>
{/* Footer stats */}
{!loading && files.length > 0 && (
<div className="px-6 py-2 border-t border-[var(--border)] text-xs text-[var(--muted)]">
{files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"} · {formatSize(files.reduce((s, f) => s + f.size, 0))}
</div>
)}
</div>
);
} }

View File

@ -1,17 +1,323 @@
import type { Project } from "@/lib/api"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import type { Project, ProjectMember, Member } from "@/lib/api";
import {
updateProject,
deleteProject,
getProjectMembers,
addProjectMember,
removeProjectMember,
getMembers,
} from "@/lib/api";
interface Props { interface Props {
project: Project; project: Project;
onUpdated: (project: Project) => void; onUpdated: (p: Project) => void;
} }
export default function ProjectSettings({ project, onUpdated }: Props) { export default function ProjectSettings({ project, onUpdated }: Props) {
const navigate = useNavigate();
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description || "");
const [repoUrls, setRepoUrls] = useState(project.repo_urls?.join("\n") || "");
const [status, setStatus] = useState(project.status);
const [autoAssign, setAutoAssign] = useState(project.auto_assign || false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
// Members state
const [members, setMembers] = useState<ProjectMember[]>([]);
const [allMembers, setAllMembers] = useState<Member[]>([]);
const [loadingMembers, setLoadingMembers] = useState(true);
const [selectedMember, setSelectedMember] = useState("");
// Labels removed — now in app settings
// Load project members and all members
useEffect(() => {
const loadMembers = async () => {
try {
const [projectMembers, allMembersList] = await Promise.all([
getProjectMembers(project.id),
getMembers()
]);
setMembers(projectMembers);
setAllMembers(allMembersList);
// Labels now in app settings
} catch (e) {
console.error("Failed to load members:", e);
} finally {
setLoadingMembers(false);
}
};
loadMembers();
}, [project.slug]);
// Get available members for adding (not already in project)
const availableMembers = allMembers.filter(
(member) => !members.some((pm) => pm.slug === member.slug)
);
const handleAddMember = async () => {
if (!selectedMember) return;
try {
await addProjectMember(project.id, selectedMember);
const newMember = allMembers.find((m) => m.id === selectedMember);
if (newMember) {
setMembers([...members, {
id: newMember.id,
name: newMember.name,
slug: newMember.slug,
type: newMember.type,
role: "member"
}]);
setSelectedMember("");
}
} catch (e) {
console.error("Failed to add member:", e);
}
};
// handleRemoveMember is now inline in agent toggle
const handleSave = async () => {
setSaving(true);
try {
const urls = repoUrls.split("\n").map((u) => u.trim()).filter(Boolean);
const updated = await updateProject(project.id, {
name: name.trim(),
description: description.trim() || null,
repo_urls: urls,
status,
});
onUpdated(updated);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
try {
await deleteProject(project.id);
navigate("/");
} catch (e) {
console.error(e);
}
};
return ( return (
<div className="flex items-center justify-center h-full text-[var(--muted)]"> <div className="max-w-xl mx-auto p-6 pb-20 space-y-6 overflow-y-auto h-full">
<div className="text-center"> <h2 className="text-lg font-bold">Настройки проекта</h2>
<h3 className="text-lg mb-2"> Project Settings</h3>
<p className="text-sm">Project: {project.name}</p> <div>
<p className="text-xs mt-1">Coming soon...</p> <label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm resize-y"
rows={3}
placeholder="Описание проекта..."
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Репозитории (по одному на строку)</label>
<textarea
value={repoUrls}
onChange={(e) => setRepoUrls(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm font-mono resize-y"
rows={3}
placeholder="https://github.com/..."
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Статус</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="active">Active</option>
<option value="archived">Archived</option>
<option value="paused">Paused</option>
</select>
</div>
{/* Auto-assign toggle */}
<div className="flex items-center justify-between">
<div>
<div className="text-sm">Авто-назначение</div>
<div className="text-xs text-[var(--muted)]">Назначать задачи агентам по лейблам capabilities</div>
</div>
<button
onClick={async () => {
const newVal = !autoAssign;
setAutoAssign(newVal);
try {
const updated = await updateProject(project.id, { auto_assign: newVal });
onUpdated(updated);
} catch (e) { setAutoAssign(!newVal); }
}}
className={`relative w-10 h-5 rounded-full transition-colors duration-200 ${
autoAssign ? "bg-[var(--accent)]" : "bg-[var(--border)]"
}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
autoAssign ? "translate-x-5" : "translate-x-0"
}`} />
</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
>
{saving ? "..." : "Сохранить"}
</button>
{saved && <span className="text-sm text-green-400">Сохранено </span>}
</div>
{/* Labels Section */}
{/* Members Section */}
<div className="pt-6 border-t border-[var(--border)]">
<div className="text-sm font-medium mb-3">Участники</div>
{loadingMembers ? (
<div className="text-sm text-[var(--muted)]">Загрузка...</div>
) : (
<div className="space-y-3">
{/* Humans (non-removable except by dropdown) */}
<div className="space-y-2">
{members.filter(m => m.type !== "agent").map((member) => (
<div key={member.slug} className="flex items-center justify-between p-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg">
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium bg-green-500/20 text-green-400">
H
</div>
<div>
<div className="text-sm font-medium">{member.name}</div>
<div className="text-xs text-[var(--muted)]">{member.slug} {member.role}</div>
</div>
</div>
</div>
))}
</div>
{/* Agents with toggle switches */}
<div className="text-xs text-[var(--muted)] mt-4 mb-2">Агенты</div>
<div className="space-y-2">
{allMembers.filter(m => m.type === "agent").map((agent) => {
const isActive = members.some(pm => pm.id === agent.id);
return (
<div key={agent.id} className="flex items-center justify-between p-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg">
<div className="flex items-center gap-3">
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium bg-blue-500/20 text-blue-400">
A
</div>
<div>
<div className="text-sm font-medium">{agent.name}</div>
<div className="text-xs text-[var(--muted)]">{agent.slug}</div>
</div>
</div>
<button
onClick={async () => {
try {
if (isActive) {
await removeProjectMember(project.id, agent.id);
setMembers(members.filter(m => m.id !== agent.id));
} else {
await addProjectMember(project.id, agent.id);
setMembers([...members, { id: agent.id, name: agent.name, slug: agent.slug, type: agent.type, role: "member" } as ProjectMember]);
}
} catch (e) {
console.error("Failed to toggle agent:", e);
}
}}
className={`relative w-10 h-5 rounded-full transition-colors duration-200 ${
isActive ? "bg-[var(--accent)]" : "bg-[var(--border)]"
}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
isActive ? "translate-x-5" : "translate-x-0"
}`} />
</button>
</div>
);
})}
</div>
{/* Add human member */}
{availableMembers.filter(m => m.type !== "agent").length > 0 && (
<div className="flex gap-2 mt-3">
<select
value={selectedMember}
onChange={(e) => setSelectedMember(e.target.value)}
className="flex-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="">Добавить участника...</option>
{availableMembers.filter(m => m.type !== "agent").map((member) => (
<option key={member.id} value={member.id}>
{member.name} ({member.slug}) - {member.type}
</option>
))}
</select>
<button
onClick={handleAddMember}
disabled={!selectedMember}
className="px-3 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
>
Добавить
</button>
</div>
)}
</div>
)}
</div>
{/* Danger Zone */}
<div className="pt-6 border-t border-[var(--border)]">
<div className="text-sm text-[var(--muted)] mb-2">Зона опасности</div>
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400">Проект и все задачи будут удалены!</span>
<button
onClick={handleDelete}
className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30"
>
Да, удалить
</button>
<button
onClick={() => setConfirmDelete(false)}
className="px-3 py-1.5 text-xs text-[var(--muted)]"
>
Отмена
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="text-xs text-red-400/60 hover:text-red-400"
>
Удалить проект
</button>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,3 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Project } from "@/lib/api"; import type { Project } from "@/lib/api";
@ -5,10 +6,10 @@ import { logout } from "@/lib/auth-client";
interface Props { interface Props {
projects: Project[]; projects: Project[];
activeSlug?: string; activeId?: string;
} }
export default function Sidebar({ projects, activeSlug }: Props) { export default function Sidebar({ projects, activeId }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const nav = ( const nav = (
@ -41,10 +42,10 @@ export default function Sidebar({ projects, activeSlug }: Props) {
{projects.map((p) => ( {projects.map((p) => (
<Link <Link
key={p.id} key={p.id}
to={`/projects/${p.slug}`} to={`/projects/${p.id}`}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className={`block px-3 py-2 rounded text-sm transition-colors ${ className={`block px-3 py-2 rounded text-sm transition-colors ${
activeSlug === p.slug activeId === p.id
? "bg-[var(--accent)]/10 text-[var(--accent)]" ? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-white/5 text-[var(--fg)]" : "hover:bg-white/5 text-[var(--fg)]"
}`} }`}
@ -98,7 +99,7 @@ export default function Sidebar({ projects, activeSlug }: Props) {
{/* Sidebar: always visible on desktop, slide-in on mobile */} {/* Sidebar: always visible on desktop, slide-in on mobile */}
<aside <aside
className={` className={`
fixed md:static z-50 top-0 left-0 h-screen w-60 shrink-0 fixed md:static z-50 top-0 left-0 h-[100dvh] w-60 shrink-0
border-r border-[var(--border)] bg-[var(--card)] flex flex-col border-r border-[var(--border)] bg-[var(--card)] flex flex-col
transition-transform duration-200 transition-transform duration-200
${open ? "translate-x-0" : "-translate-x-full md:translate-x-0"} ${open ? "translate-x-0" : "-translate-x-full md:translate-x-0"}

View File

@ -0,0 +1,526 @@
import { useEffect, useState } from "react";
import type { Task, Member, Step, Message, TaskLink } from "@/lib/api";
import CreateTaskModal from "@/components/CreateTaskModal";
import {
updateTask, deleteTask, getMembers,
getSteps, createStep, updateStep, deleteStep as _deleteStepApi,
getMessages, sendMessage,
getTaskLinks, createTaskLink, deleteTaskLink,
searchTasks,
} from "@/lib/api";
const STATUSES = [
{ key: "backlog", label: "Backlog", color: "#737373" },
{ key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "in_review", label: "Review", color: "#a855f7" },
{ key: "done", label: "Done", color: "#22c55e" },
];
const PRIORITIES = [
{ key: "low", label: "Low", color: "#737373" },
{ key: "medium", label: "Medium", color: "#3b82f6" },
{ key: "high", label: "High", color: "#f59e0b" },
{ key: "critical", label: "Critical", color: "#ef4444" },
];
const AUTHOR_ICON: Record<string, string> = {
human: "👤",
agent: "🤖",
system: "⚙️",
};
interface Props {
task: Task;
projectId: string;
projectSlug: string;
onClose: () => void;
onUpdated: (task: Task) => void;
onDeleted: (taskId: string) => void;
onOpenTask?: (taskId: string) => void;
}
export default function TaskModal({ task, projectId: _projectId, projectSlug: _projectSlug, onClose, onUpdated, onDeleted, onOpenTask }: Props) {
const [title, setTitle] = useState(task.title);
const [description, setDescription] = useState(task.description || "");
const [status, setStatus] = useState(task.status);
const [priority, setPriority] = useState(task.priority);
const [assigneeId, setAssigneeId] = useState(task.assignee_id || "");
const [members, setMembers] = useState<Member[]>([]);
const [steps, setSteps] = useState<Step[]>(task.steps || []);
const [links, setLinks] = useState<TaskLink[]>([]);
const [showAddLink, setShowAddLink] = useState(false);
const [linkTargetId, setLinkTargetId] = useState("");
const [linkSearch, setLinkSearch] = useState("");
const [linkSearchResults, setLinkSearchResults] = useState<Task[]>([]);
const [linkType, setLinkType] = useState("depends_on");
const [comments, setComments] = useState<Message[]>([]);
const [_saving, setSaving] = useState(false);
const [editingDesc, setEditingDesc] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [showCreateSubtask, setShowCreateSubtask] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [newStep, setNewStep] = useState("");
const [newComment, setNewComment] = useState("");
useEffect(() => {
getMembers().then(setMembers).catch(() => {});
getSteps(task.id).then(setSteps).catch(() => {});
getTaskLinks(task.id).then(setLinks).catch(() => {});
getMessages({ task_id: task.id }).then(setComments).catch(() => {});
}, [task.id]);
const save = async (patch: Partial<Task>) => {
setSaving(true);
try {
const updated = await updateTask(task.id, patch);
onUpdated(updated);
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
try {
await deleteTask(task.id);
onDeleted(task.id);
onClose();
} catch (e) {
console.error(e);
}
};
const handleAddStep = async () => {
if (!newStep.trim()) return;
try {
const step = await createStep(task.id, newStep.trim());
setSteps((prev) => [...prev, step]);
setNewStep("");
} catch (e) {
console.error(e);
}
};
const handleToggleStep = async (step: Step) => {
try {
const updated = await updateStep(task.id, step.id, { done: !step.done });
setSteps((prev) => prev.map((s) => (s.id === step.id ? updated : s)));
} catch (e) {
console.error(e);
}
};
const handleAddComment = async () => {
if (!newComment.trim()) return;
try {
const msg = await sendMessage({ task_id: task.id, content: newComment.trim() });
setComments((prev) => [...prev, msg]);
setNewComment("");
} catch (e) {
console.error(e);
}
};
return (
<div
className="fixed inset-0 bg-black/60 flex items-start justify-center z-50 overflow-y-auto py-8 px-4"
onClick={onClose}
>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-4 md:p-6 border-b border-[var(--border)]">
<div className="flex items-center justify-between mb-1">
<div className="text-xs text-[var(--muted)]">
{task.parent_key && task.parent_id ? (
<span>
<button
onClick={() => { onClose(); setTimeout(() => onOpenTask?.(task.parent_id!), 100); }}
className="text-[var(--accent)] hover:underline"
>{task.parent_key}</button>
<span className="mx-1">/</span>
</span>
) : null}
{task.key}
</div>
<button
onClick={onClose}
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors text-xl leading-none cursor-pointer shrink-0"
title="Закрыть"
>
</button>
</div>
<div className="flex items-start gap-2">
{editingTitle ? (
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => {
setEditingTitle(false);
if (title.trim() && title !== task.title) save({ title: title.trim() });
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") { setTitle(task.title); setEditingTitle(false); }
}}
className="w-full text-xl font-bold bg-transparent border-b border-[var(--accent)] outline-none pb-1"
/>
) : (
<h2
className="text-xl font-bold cursor-pointer hover:text-[var(--accent)] transition-colors"
onClick={() => setEditingTitle(true)}
>
{title}
</h2>
)}
</div>
</div>
<div className="flex flex-col md:flex-row">
{/* Left: Description + Steps + Comments */}
<div className="flex-1 p-4 md:p-6 border-b md:border-b-0 md:border-r border-[var(--border)] space-y-6">
{/* Description */}
<div>
<div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between">
<span>Описание</span>
{!editingDesc && (
<button onClick={() => setEditingDesc(true)} className="text-xs text-[var(--accent)] hover:underline">
Редактировать
</button>
)}
</div>
{editingDesc ? (
<div>
<textarea
autoFocus
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] resize-y min-h-[120px]"
rows={6}
placeholder="Описание задачи (Markdown)..."
/>
<div className="flex gap-2 mt-2">
<button
onClick={() => { setEditingDesc(false); save({ description: description.trim() || undefined }); }}
className="px-3 py-1 bg-[var(--accent)] text-white rounded text-xs"
>
Сохранить
</button>
<button
onClick={() => { setDescription(task.description || ""); setEditingDesc(false); }}
className="px-3 py-1 text-[var(--muted)] text-xs"
>
Отмена
</button>
</div>
</div>
) : description ? (
<div className="text-sm whitespace-pre-wrap">{description}</div>
) : (
<div className="text-sm text-[var(--muted)] italic">Нет описания</div>
)}
</div>
{/* Steps */}
<div>
<div className="text-sm text-[var(--muted)] mb-2">
Этапы {steps.length > 0 && `(${steps.filter((s) => s.done).length}/${steps.length})`}
</div>
<div className="space-y-1">
{steps.map((step) => (
<label key={step.id} className="flex items-center gap-2 text-sm cursor-pointer group">
<input
type="checkbox"
checked={step.done}
onChange={() => handleToggleStep(step)}
className="accent-[var(--accent)]"
/>
<span className={step.done ? "line-through text-[var(--muted)]" : ""}>{step.title}</span>
</label>
))}
</div>
<div className="flex gap-2 mt-2">
<input
value={newStep}
onChange={(e) => setNewStep(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddStep()}
placeholder="Новый этап..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1 text-xs outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAddStep} className="text-xs text-[var(--accent)]">+</button>
</div>
</div>
{/* Subtasks */}
<div>
<div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between">
<span>Подзадачи{task.subtasks?.length ? ` (${task.subtasks.length})` : ""}</span>
<button
onClick={() => setShowCreateSubtask(true)}
className="text-xs text-[var(--accent)] hover:underline"
>+ Подзадача</button>
</div>
{task.subtasks && task.subtasks.length > 0 && (
<div className="space-y-1">
{task.subtasks.map((sub) => (
<div key={sub.id} className="flex items-center justify-between p-1.5 bg-[var(--bg)] border border-[var(--border)] rounded text-xs">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${
sub.status === "done" ? "bg-green-500" :
sub.status === "in_progress" ? "bg-blue-500" :
sub.status === "in_review" ? "bg-yellow-500" : "bg-gray-500"
}`} />
<button
onClick={() => { onClose(); setTimeout(() => onOpenTask?.(sub.id), 100); }}
className="text-[var(--accent)] hover:underline font-mono"
>{sub.key}</button>
<span>{sub.title}</span>
</div>
{sub.assignee && (
<span className="text-[var(--muted)]"> {sub.assignee.slug}</span>
)}
</div>
))}
</div>
)}
</div>
{showCreateSubtask && (
<CreateTaskModal
projectId={task.project_id}
initialStatus="todo"
parentId={task.id}
parentKey={task.key}
onClose={() => setShowCreateSubtask(false)}
onCreated={(newTask) => {
// Refresh parent task to show new subtask
onUpdated({ ...task, subtasks: [...(task.subtasks || []), { id: newTask.id, key: newTask.key, title: newTask.title, status: newTask.status, assignee: newTask.assignee }] });
setShowCreateSubtask(false);
}}
/>
)}
{/* Dependencies */}
{(links.length > 0 || showAddLink) && (
<div>
<div className="text-sm text-[var(--muted)] mb-2">Зависимости</div>
<div className="space-y-1 mb-2">
{links.map((link) => {
const label = link.link_type === "blocks" ? "🔴 Блокирует" :
link.link_type === "blocked_by" ? "🟡 Заблокировано" :
link.link_type === "depends_on" ? "🟡 Зависит от" :
link.link_type === "required_by" ? "🔵 Требуется для" :
"🔗 Связано с";
const title = link.target_title || link.source_title || "...";
return (
<div key={link.id} className="flex items-center justify-between text-xs p-1.5 bg-[var(--bg)] border border-[var(--border)] rounded">
<span>{label}: <span className="font-medium">{title}</span></span>
<button
onClick={async () => {
await deleteTaskLink(task.id, link.id);
setLinks(links.filter(l => l.id !== link.id));
}}
className="text-red-400 hover:text-red-300 ml-2"
>×</button>
</div>
);
})}
</div>
{showAddLink && (
<div className="flex gap-1 mb-2">
<select value={linkType} onChange={e => setLinkType(e.target.value)}
className="px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs">
<option value="depends_on">Зависит от</option>
<option value="blocks">Блокирует</option>
<option value="relates_to">Связано с</option>
</select>
<div className="flex-1 relative">
<input
value={linkSearch}
onChange={async (e) => {
const v = e.target.value;
setLinkSearch(v);
if (v.length >= 1) {
const results = await searchTasks(task.project_id, v);
setLinkSearchResults(results.filter(t => t.id !== task.id));
} else {
setLinkSearchResults([]);
}
}}
placeholder="Поиск по номеру или названию..."
className="w-full px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs outline-none"
/>
{linkSearchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded shadow-lg z-10 max-h-40 overflow-y-auto">
{linkSearchResults.map(t => (
<button
key={t.id}
onClick={() => {
setLinkTargetId(t.id);
setLinkSearch(`${t.key} ${t.title}`);
setLinkSearchResults([]);
}}
className="w-full text-left px-2 py-1.5 text-xs hover:bg-white/5 flex gap-2"
>
<span className="text-[var(--accent)] font-mono shrink-0">{t.key}</span>
<span className="truncate">{t.title}</span>
</button>
))}
</div>
)}
</div>
<button
onClick={async () => {
if (!linkTargetId) return;
const link = await createTaskLink(task.id, linkTargetId, linkType);
setLinks([...links, link]);
setLinkTargetId("");
setLinkSearch("");
setShowAddLink(false);
}}
className="px-2 py-1 bg-[var(--accent)] text-white rounded text-xs"
>+</button>
</div>
)}
</div>
)}
<button
onClick={() => setShowAddLink(!showAddLink)}
className="text-xs text-[var(--accent)] hover:underline mb-2"
>
{showAddLink ? "Скрыть" : "+ Добавить зависимость"}
</button>
{/* Comments */}
<div>
<div className="text-sm text-[var(--muted)] mb-2">Комментарии</div>
<div className="flex gap-2 mb-3">
<input
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
placeholder="Написать комментарий..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAddComment} className="text-xs text-[var(--accent)] px-2"></button>
</div>
<div className="space-y-3 max-h-[300px] overflow-y-auto">
{[...comments].reverse().map((msg) => (
<div key={msg.id} className="text-sm">
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
<span className="font-medium">{msg.author_type === "system" ? "System" : (msg.author?.name || msg.author?.slug || "Unknown")}</span>
<span>·</span>
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
</div>
))}
{comments.length === 0 && (
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
)}
</div>
</div>
</div>
{/* Right: Properties */}
<div className="w-full md:w-64 shrink-0 p-4 md:p-6 space-y-4">
{/* Status */}
<div>
<div className="text-xs text-[var(--muted)] mb-1">Статус</div>
<div className="flex flex-wrap gap-1">
{STATUSES.map((s) => (
<button
key={s.key}
onClick={() => { setStatus(s.key); save({ status: s.key }); }}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${status === s.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
{s.label}
</button>
))}
</div>
</div>
{/* Priority */}
<div>
<div className="text-xs text-[var(--muted)] mb-1">Приоритет</div>
<div className="flex flex-wrap gap-1">
{PRIORITIES.map((p) => (
<button
key={p.key}
onClick={() => { setPriority(p.key); save({ priority: p.key }); }}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
${priority === p.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
{p.label}
</button>
))}
</div>
</div>
{/* Assignee */}
<div>
<div className="text-xs text-[var(--muted)] mb-1">Исполнитель</div>
<select
value={assigneeId}
onChange={(e) => {
setAssigneeId(e.target.value);
save({ assignee_id: e.target.value || undefined } as any);
}}
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
>
<option value="">Не назначен</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.type === "agent" ? "🤖 " : "👤 "}{m.name}
</option>
))}
</select>
</div>
{/* Labels */}
{task.labels && task.labels.length > 0 && (
<div>
<div className="text-xs text-[var(--muted)] mb-1">Лейблы</div>
<div className="flex flex-wrap gap-1">
{task.labels.map((l) => (
<span key={l} className="text-xs px-2 py-0.5 rounded bg-white/10">{l}</span>
))}
</div>
</div>
)}
{/* Delete */}
<div className="pt-4 border-t border-[var(--border)]">
{confirmDelete ? (
<div className="flex gap-2">
<button onClick={handleDelete} className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30">
Да, удалить
</button>
<button onClick={() => setConfirmDelete(false)} className="px-3 py-1.5 text-[var(--muted)] text-xs">
Отмена
</button>
</div>
) : (
<button onClick={() => setConfirmDelete(true)} className="text-xs text-red-400/60 hover:text-red-400">
Удалить задачу
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -9,6 +9,17 @@ function getToken(): string | null {
return localStorage.getItem("tb_token"); return localStorage.getItem("tb_token");
} }
export function getCurrentMemberId(): string {
const token = getToken();
if (!token) return "unknown";
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.sub || "unknown";
} catch {
return "unknown";
}
}
async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> { async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken(); const token = getToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -33,8 +44,17 @@ async function request<T = any>(path: string, options: RequestInit = {}): Promis
// --- Types --- // --- Types ---
// --- Shared types (1:1 with backend Pydantic schemas) ---
export interface MemberBrief {
id: string;
slug: string;
name: string;
}
export interface AgentConfig { export interface AgentConfig {
capabilities: string[]; capabilities: string[];
labels: string[];
chat_listen: string; chat_listen: string;
task_listen: string; task_listen: string;
prompt: string | null; prompt: string | null;
@ -43,14 +63,15 @@ export interface AgentConfig {
export interface Member { export interface Member {
id: string; id: string;
name: string;
slug: string; slug: string;
type: "human" | "agent"; name: string;
type: string;
role: string; role: string;
status: string; status: string;
avatar_url: string | null; avatar_url: string | null;
agent_config: AgentConfig | null; is_active: boolean;
token?: string | null; token?: string | null;
agent_config?: AgentConfig | null;
} }
export interface MemberCreateResponse extends Member { export interface MemberCreateResponse extends Member {
@ -66,13 +87,24 @@ export interface Project {
status: string; status: string;
task_counter: number; task_counter: number;
chat_id: string | null; chat_id: string | null;
auto_assign: boolean;
}
export interface ProjectMember {
id: string;
name: string;
slug: string;
type: string;
role: string;
} }
export interface Step { export interface Step {
id: string; id: string;
task_id: string;
title: string; title: string;
done: boolean; done: boolean;
position: number; position: number;
created_at: string;
} }
export interface Task { export interface Task {
@ -87,13 +119,28 @@ export interface Task {
status: string; status: string;
priority: string; priority: string;
labels: string[]; labels: string[];
assignee_slug: string | null; assignee_id: string | null;
reviewer_slug: string | null; assignee: MemberBrief | null;
watchers: string[]; reviewer_id: string | null;
reviewer: MemberBrief | null;
watcher_ids: string[];
depends_on: string[]; depends_on: string[];
position: number; position: number;
time_spent: number; time_spent: number;
steps: Step[]; steps: Step[];
subtasks: SubtaskBrief[];
parent_key: string | null;
parent_title: string | null;
created_at: string;
updated_at: string;
}
export interface SubtaskBrief {
id: string;
key: string;
title: string;
status: string;
assignee: MemberBrief | null;
} }
export interface Attachment { export interface Attachment {
@ -109,9 +156,13 @@ export interface Message {
task_id: string | null; task_id: string | null;
parent_id: string | null; parent_id: string | null;
author_type: string; author_type: string;
author_slug: string; author_id: string | null;
author: MemberBrief | null;
content: string; content: string;
mentions: string[]; thinking: string | null;
tool_log: Array<{name: string; args?: string; result?: string; error?: boolean}> | null;
mentions: MemberBrief[];
actor: MemberBrief | null;
voice_url: string | null; voice_url: string | null;
attachments: Attachment[]; attachments: Attachment[];
created_at: string; created_at: string;
@ -120,7 +171,7 @@ export interface Message {
// --- Auth --- // --- Auth ---
export async function login(login: string, password: string) { export async function login(login: string, password: string) {
return request("/api/auth/login", { return request("/api/v1/auth/login", {
method: "POST", method: "POST",
body: JSON.stringify({ login, password }), body: JSON.stringify({ login, password }),
}); });
@ -132,20 +183,20 @@ export async function getProjects(): Promise<Project[]> {
return request("/api/v1/projects"); return request("/api/v1/projects");
} }
export async function getProject(slug: string): Promise<Project> { export async function getProject(projectId: string): Promise<Project> {
return request(`/api/v1/projects/${slug}`); return request(`/api/v1/projects/${projectId}`);
} }
export async function createProject(data: { name: string; slug: string; description?: string }): Promise<Project> { export async function createProject(data: { name: string; slug: string; description?: string }): Promise<Project> {
return request("/api/v1/projects", { method: "POST", body: JSON.stringify(data) }); return request("/api/v1/projects", { method: "POST", body: JSON.stringify(data) });
} }
export async function updateProject(slug: string, data: Partial<Pick<Project, "name" | "description" | "repo_urls" | "status">>): Promise<Project> { export async function updateProject(projectId: string, data: Partial<Pick<Project, "name" | "slug" | "description" | "repo_urls" | "status" | "auto_assign">>): Promise<Project> {
return request(`/api/v1/projects/${slug}`, { method: "PATCH", body: JSON.stringify(data) }); return request(`/api/v1/projects/${projectId}`, { method: "PATCH", body: JSON.stringify(data) });
} }
export async function deleteProject(slug: string): Promise<void> { export async function deleteProject(projectId: string): Promise<void> {
await request(`/api/v1/projects/${slug}`, { method: "DELETE" }); await request(`/api/v1/projects/${projectId}`, { method: "DELETE" });
} }
// --- Tasks --- // --- Tasks ---
@ -158,8 +209,8 @@ export async function getTask(taskId: string): Promise<Task> {
return request(`/api/v1/tasks/${taskId}`); return request(`/api/v1/tasks/${taskId}`);
} }
export async function createTask(projectSlug: string, data: Partial<Task>): Promise<Task> { export async function createTask(projectId: string, data: Partial<Task>): Promise<Task> {
return request(`/api/v1/tasks?project_slug=${projectSlug}`, { return request(`/api/v1/tasks?project_id=${projectId}`, {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
@ -173,8 +224,8 @@ export async function deleteTask(taskId: string): Promise<void> {
await request(`/api/v1/tasks/${taskId}`, { method: "DELETE" }); await request(`/api/v1/tasks/${taskId}`, { method: "DELETE" });
} }
export async function takeTask(taskId: string, slug: string): Promise<Task> { export async function takeTask(taskId: string): Promise<Task> {
return request(`/api/v1/tasks/${taskId}/take?slug=${slug}`, { method: "POST" }); return request(`/api/v1/tasks/${taskId}/take`, { method: "POST" });
} }
export async function rejectTask(taskId: string, reason: string): Promise<{ok: boolean; reason: string; old_assignee: string}> { export async function rejectTask(taskId: string, reason: string): Promise<{ok: boolean; reason: string; old_assignee: string}> {
@ -184,19 +235,19 @@ export async function rejectTask(taskId: string, reason: string): Promise<{ok: b
}); });
} }
export async function assignTask(taskId: string, assigneeSlug: string): Promise<Task> { export async function assignTask(taskId: string, assigneeId: string): Promise<Task> {
return request(`/api/v1/tasks/${taskId}/assign`, { return request(`/api/v1/tasks/${taskId}/assign`, {
method: "POST", method: "POST",
body: JSON.stringify({ assignee_slug: assigneeSlug }) body: JSON.stringify({ assignee_id: assigneeId })
}); });
} }
export async function watchTask(taskId: string, slug: string): Promise<{ok: boolean; watchers: string[]}> { export async function watchTask(taskId: string): Promise<{ok: boolean; watcher_ids: string[]}> {
return request(`/api/v1/tasks/${taskId}/watch?slug=${slug}`, { method: "POST" }); return request(`/api/v1/tasks/${taskId}/watch`, { method: "POST" });
} }
export async function unwatchTask(taskId: string, slug: string): Promise<{ok: boolean; watchers: string[]}> { export async function unwatchTask(taskId: string): Promise<{ok: boolean; watcher_ids: string[]}> {
return request(`/api/v1/tasks/${taskId}/watch?slug=${slug}`, { method: "DELETE" }); return request(`/api/v1/tasks/${taskId}/watch`, { method: "DELETE" });
} }
// --- Steps --- // --- Steps ---
@ -225,11 +276,12 @@ export async function deleteStep(taskId: string, stepId: string): Promise<void>
// --- Messages (unified: chat + task comments) --- // --- Messages (unified: chat + task comments) ---
export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number }): Promise<Message[]> { export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number; offset?: number }): Promise<Message[]> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.chat_id) qs.set("chat_id", params.chat_id); if (params.chat_id) qs.set("chat_id", params.chat_id);
if (params.task_id) qs.set("task_id", params.task_id); if (params.task_id) qs.set("task_id", params.task_id);
if (params.limit) qs.set("limit", String(params.limit)); if (params.limit) qs.set("limit", String(params.limit));
if (params.offset) qs.set("offset", String(params.offset));
return request(`/api/v1/messages?${qs}`); return request(`/api/v1/messages?${qs}`);
} }
@ -238,6 +290,7 @@ export async function sendMessage(data: {
task_id?: string; task_id?: string;
content: string; content: string;
mentions?: string[]; mentions?: string[];
attachments?: { file_id: string; filename: string; mime_type: string | null; size: number; storage_name: string }[];
}): Promise<Message> { }): Promise<Message> {
return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) }); return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) });
} }
@ -248,8 +301,8 @@ export async function getMembers(): Promise<Member[]> {
return request("/api/v1/members"); return request("/api/v1/members");
} }
export async function getMember(slug: string): Promise<Member> { export async function getMember(memberId: string): Promise<Member> {
return request(`/api/v1/members/${slug}`); return request(`/api/v1/members/${memberId}`);
} }
export async function createMember(data: { export async function createMember(data: {
@ -261,19 +314,182 @@ export async function createMember(data: {
return request("/api/v1/members", { method: "POST", body: JSON.stringify(data) }); return request("/api/v1/members", { method: "POST", body: JSON.stringify(data) });
} }
export async function updateMember(slug: string, data: { export async function updateMember(memberId: string, data: {
name?: string; name?: string;
slug?: string;
role?: string; role?: string;
status?: string; status?: string;
agent_config?: Partial<AgentConfig>; agent_config?: Partial<AgentConfig>;
}): Promise<Member> { }): Promise<Member> {
return request(`/api/v1/members/${slug}`, { method: "PATCH", body: JSON.stringify(data) }); return request(`/api/v1/members/${memberId}`, { method: "PATCH", body: JSON.stringify(data) });
} }
export async function regenerateToken(slug: string): Promise<{ token: string }> { export async function regenerateToken(memberId: string): Promise<{ token: string }> {
return request(`/api/v1/members/${slug}/regenerate-token`, { method: "POST" }); return request(`/api/v1/members/${memberId}/regenerate-token`, { method: "POST" });
} }
export async function revokeToken(slug: string): Promise<void> { export async function revokeToken(memberId: string): Promise<void> {
await request(`/api/v1/members/${slug}/revoke-token`, { method: "POST" }); await request(`/api/v1/members/${memberId}/revoke-token`, { method: "POST" });
}
// --- Project Files ---
export interface ProjectFile {
id: string;
filename: string;
description: string | null;
mime_type: string | null;
size: number;
uploaded_by: MemberBrief | null;
created_at: string;
updated_at: string;
}
export async function getProjectFiles(projectId: string, search?: string): Promise<ProjectFile[]> {
const qs = search ? `?search=${encodeURIComponent(search)}` : "";
return request(`/api/v1/projects/${projectId}/files${qs}`);
}
export async function uploadProjectFile(projectId: string, file: File, description?: string): Promise<ProjectFile> {
const token = getToken();
const formData = new FormData();
formData.append("file", file);
if (description) formData.append("description", description);
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/files`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (res.status === 401) {
localStorage.removeItem("tb_token");
window.location.href = "/login";
throw new Error("Unauthorized");
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
export function getProjectFileUrl(projectId: string, fileId: string): string {
const token = getToken();
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
return `${API_BASE}/api/v1/projects/${projectId}/files/${fileId}/download${qs}`;
}
export async function updateProjectFile(projectId: string, fileId: string, data: { description?: string }): Promise<ProjectFile> {
return request(`/api/v1/projects/${projectId}/files/${fileId}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteProjectFile(projectId: string, fileId: string): Promise<void> {
await request(`/api/v1/projects/${projectId}/files/${fileId}`, { method: "DELETE" });
}
// --- Chat File Upload ---
export interface UploadedFile {
file_id: string;
filename: string;
mime_type: string | null;
size: number;
storage_name: string;
}
export async function uploadFile(file: File): Promise<UploadedFile> {
const token = getToken();
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${API_BASE}/api/v1/upload`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (res.status === 401) {
localStorage.removeItem("tb_token");
window.location.href = "/login";
throw new Error("Unauthorized");
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
export function getAttachmentUrl(attachmentId: string): string {
const token = getToken();
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
return `${API_BASE}/api/v1/attachments/${attachmentId}/download${qs}`;
}
// --- Project Members ---
export async function getProjectMembers(projectId: string): Promise<ProjectMember[]> {
return request(`/api/v1/projects/${projectId}/members`);
}
export async function addProjectMember(projectId: string, memberId: string): Promise<{ ok: boolean }> {
return request(`/api/v1/projects/${projectId}/members`, {
method: "POST",
body: JSON.stringify({ member_id: memberId })
});
}
export async function removeProjectMember(projectId: string, memberId: string): Promise<{ ok: boolean }> {
return request(`/api/v1/projects/${projectId}/members/${memberId}`, { method: "DELETE" });
}
// --- Task Links ---
export interface TaskLink {
id: string;
source_id: string;
target_id: string;
link_type: string;
target_title?: string;
source_title?: string;
}
export async function getTaskLinks(taskId: string): Promise<TaskLink[]> {
return request(`/tasks/${taskId}/links`);
}
export async function createTaskLink(taskId: string, targetId: string, linkType: string): Promise<TaskLink> {
return request(`/tasks/${taskId}/links`, { method: "POST", body: JSON.stringify({ target_id: targetId, link_type: linkType }) });
}
export async function deleteTaskLink(taskId: string, linkId: string): Promise<void> {
return request(`/tasks/${taskId}/links/${linkId}`, { method: "DELETE" });
}
// --- Labels ---
export interface Label {
id: string;
name: string;
color: string;
}
export async function getLabels(): Promise<Label[]> {
return request(`/labels`);
}
export async function createLabel(name: string, color: string = "#6366f1"): Promise<Label> {
return request(`/labels`, { method: "POST", body: JSON.stringify({ name, color }) });
}
export async function deleteLabel(labelId: string): Promise<void> {
return request(`/labels/${labelId}`, { method: "DELETE" });
}
export async function addTaskLabel(taskId: string, labelId: string): Promise<void> {
return request(`/tasks/${taskId}/labels/${labelId}`, { method: "POST" });
}
export async function removeTaskLabel(taskId: string, labelId: string): Promise<void> {
return request(`/tasks/${taskId}/labels/${labelId}`, { method: "DELETE" });
}
export async function searchTasks(projectId: string, query: string): Promise<Task[]> {
return request(`/tasks?project_id=${projectId}&q=${encodeURIComponent(query)}`);
} }

View File

@ -9,11 +9,12 @@ class WSClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private handlers: Map<string, Set<MessageHandler>> = new Map(); private handlers: Map<string, Set<MessageHandler>> = new Map();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pendingQueue: any[] = []; private pendingQueue: any[] = [];
private authenticated = false; private authenticated = false;
private _lobbyId: string | null = null; private _lobbyId: string | null = null;
private _projects: Array<{ id: string; slug: string; name: string }> = []; private _projects: Array<{ id: string; slug: string; name: string }> = [];
private _online: string[] = []; private _online: Array<{id: string; slug: string}> = [];
get lobbyId() { return this._lobbyId; } get lobbyId() { return this._lobbyId; }
get projects() { return this._projects; } get projects() { return this._projects; }
@ -38,7 +39,7 @@ class WSClient {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
const type = msg.type; const type = msg.type;
// Handle auth.ok — flush pending queue // Handle auth.ok — flush pending queue + start heartbeat
if (type === "auth.ok") { if (type === "auth.ok") {
this._lobbyId = msg.data?.lobby_chat_id || null; this._lobbyId = msg.data?.lobby_chat_id || null;
this._projects = msg.data?.projects || []; this._projects = msg.data?.projects || [];
@ -49,6 +50,16 @@ class WSClient {
this.ws!.send(JSON.stringify(queued)); this.ws!.send(JSON.stringify(queued));
} }
this.pendingQueue = []; this.pendingQueue = [];
// Start heartbeat every 30s to keep connection alive
this.startHeartbeat();
}
// Auth error → force re-login
if (type === "auth.error") {
console.error("WS auth failed:", msg);
localStorage.removeItem("tb_token");
window.location.href = "/login";
return;
} }
// Dispatch to handlers // Dispatch to handlers
@ -65,6 +76,7 @@ class WSClient {
this.ws.onclose = () => { this.ws.onclose = () => {
this.authenticated = false; this.authenticated = false;
this.stopHeartbeat();
this.reconnectTimer = setTimeout(() => this.connect(), 3000); this.reconnectTimer = setTimeout(() => this.connect(), 3000);
}; };
@ -73,12 +85,27 @@ class WSClient {
disconnect() { disconnect() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.stopHeartbeat();
this.ws?.close(); this.ws?.close();
this.ws = null; this.ws = null;
this.authenticated = false; this.authenticated = false;
this.pendingQueue = []; this.pendingQueue = [];
} }
private startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
this.heartbeat();
}, 30_000);
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
on(type: string, handler: MessageHandler) { on(type: string, handler: MessageHandler) {
if (!this.handlers.has(type)) this.handlers.set(type, new Set()); if (!this.handlers.has(type)) this.handlers.set(type, new Set());
this.handlers.get(type)!.add(handler); this.handlers.get(type)!.add(handler);

View File

@ -1,10 +1,9 @@
import { StrictMode } from 'react' // StrictMode removed — causes double WS subscriptions (double useEffect)
// import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <App />,
<App />
</StrictMode>,
) )

View File

@ -5,7 +5,7 @@ export default function CreateProjectPage() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-[100dvh] items-center justify-center">
<CreateProjectModal <CreateProjectModal
onCreated={(project) => navigate(`/projects/${project.slug}`)} onCreated={(project) => navigate(`/projects/${project.slug}`)}
onClose={() => navigate(-1)} onClose={() => navigate(-1)}

View File

@ -16,7 +16,7 @@ export default function HomePage() {
if (loading) { if (loading) {
return ( return (
<div className="flex h-screen items-center justify-center text-[var(--muted)]"> <div className="flex h-[100dvh] items-center justify-center text-[var(--muted)]">
Загрузка... Загрузка...
</div> </div>
); );
@ -27,5 +27,5 @@ export default function HomePage() {
} }
// Redirect to first project // Redirect to first project
return <Navigate to={`/projects/${projects[0].slug}`} replace />; return <Navigate to={`/projects/${projects[0].id}`} replace />;
} }

View File

@ -30,7 +30,7 @@ export default function LoginPage() {
}; };
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-[100dvh] items-center justify-center">
<form onSubmit={handleSubmit} className="w-full max-w-80 px-4"> <form onSubmit={handleSubmit} className="w-full max-w-80 px-4">
<h1 className="text-3xl font-bold mb-1 text-center">Team Board</h1> <h1 className="text-3xl font-bold mb-1 text-center">Team Board</h1>
<p className="text-[var(--muted)] mb-6 text-center text-sm">AI Agent Collaboration Platform</p> <p className="text-[var(--muted)] mb-6 text-center text-sm">AI Agent Collaboration Platform</p>

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams, useSearchParams, Link } from "react-router-dom";
import { getProjects, type Project } from "@/lib/api"; import { getProjects, type Project } from "@/lib/api";
import { logout } from "@/lib/auth-client";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import KanbanBoard from "@/components/KanbanBoard"; import KanbanBoard from "@/components/KanbanBoard";
import ChatPanel from "@/components/ChatPanel"; import ChatPanel from "@/components/ChatPanel";
@ -15,10 +16,18 @@ const TABS = [
]; ];
export default function ProjectPage() { export default function ProjectPage() {
const { slug } = useParams<{ slug: string }>(); const { id: projectId } = useParams<{ id: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("board");
const validTabs = ["board", "chat", "files", "settings"];
const tabFromUrl = searchParams.get("tab") || "board";
const activeTab = validTabs.includes(tabFromUrl) ? tabFromUrl : "board";
const setActiveTab = (tab: string) => {
setSearchParams(tab === "board" ? {} : { tab }, { replace: true });
};
useEffect(() => { useEffect(() => {
getProjects() getProjects()
@ -27,23 +36,23 @@ export default function ProjectPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const project = projects.find((p) => p.slug === slug); const project = projects.find((p) => p.id === projectId);
const handleProjectUpdated = (updated: Project) => { const handleProjectUpdated = (updated: Project) => {
setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
}; };
if (loading) { if (loading) {
return <div className="flex h-screen items-center justify-center text-[var(--muted)]">Загрузка...</div>; return <div className="flex h-[100dvh] items-center justify-center text-[var(--muted)]">Загрузка...</div>;
} }
if (!project) { if (!project) {
return <div className="flex h-screen items-center justify-center text-[var(--muted)]">Проект не найден</div>; return <div className="flex h-[100dvh] items-center justify-center text-[var(--muted)]">Проект не найден</div>;
} }
return ( return (
<div className="flex h-screen"> <div className="flex h-[100dvh]">
<Sidebar projects={projects} activeSlug={slug} /> <Sidebar projects={projects} activeId={projectId} />
<main className="flex-1 flex flex-col overflow-hidden"> <main className="flex-1 flex flex-col overflow-hidden">
<header className="border-b border-[var(--border)] px-6 py-3 pl-14 md:pl-6"> <header className="border-b border-[var(--border)] px-6 py-3 pl-14 md:pl-6">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@ -53,6 +62,22 @@ export default function ProjectPage() {
<p className="text-sm text-[var(--muted)]">{project.description}</p> <p className="text-sm text-[var(--muted)]">{project.description}</p>
)} )}
</div> </div>
<div className="flex items-center gap-2 md:hidden">
<Link
to="/settings"
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors"
title="Настройки"
>
</Link>
<button
onClick={logout}
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors cursor-pointer"
title="Выйти"
>
🚪
</button>
</div>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{TABS.map((tab) => ( {TABS.map((tab) => (
@ -76,7 +101,7 @@ export default function ProjectPage() {
<KanbanBoard projectId={project.id} projectSlug={project.slug} /> <KanbanBoard projectId={project.id} projectSlug={project.slug} />
)} )}
{activeTab === "chat" && project.chat_id && ( {activeTab === "chat" && project.chat_id && (
<ChatPanel chatId={project.chat_id} fullscreen /> <ChatPanel chatId={project.chat_id} projectId={project.id} />
)} )}
{activeTab === "chat" && !project.chat_id && ( {activeTab === "chat" && !project.chat_id && (
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">

View File

@ -0,0 +1,67 @@
import { useState, useEffect } from "react";
import type { Label } from "@/lib/api";
import { getLabels, createLabel, deleteLabel } from "@/lib/api";
export default function LabelsPage() {
const [labels, setLabels] = useState<Label[]>([]);
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState("#6366f1");
useEffect(() => {
getLabels().then(setLabels).catch(() => {});
}, []);
const handleCreate = async () => {
if (!newName.trim()) return;
const label = await createLabel(newName.trim(), newColor);
setLabels([...labels, label]);
setNewName("");
};
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold mb-2">🏷 Лейблы</h1>
<p className="text-xs text-[var(--muted)] mb-6">Глобальные лейблы для задач и агентов. Используются для авто-назначения.</p>
<div className="space-y-2 mb-4">
{labels.map((label) => (
<div key={label.id} className="flex items-center justify-between p-2.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: label.color }} />
<span className="text-sm">{label.name}</span>
</div>
<button
onClick={async () => {
await deleteLabel(label.id);
setLabels(labels.filter(l => l.id !== label.id));
}}
className="text-xs text-red-400 hover:text-red-300"
>Удалить</button>
</div>
))}
{labels.length === 0 && <p className="text-sm text-[var(--muted)]">Нет лейблов. Создайте первый.</p>}
</div>
<div className="flex gap-2">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleCreate()}
placeholder="Новый лейбл..."
className="flex-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none focus:border-[var(--accent)]"
/>
<input
type="color"
value={newColor}
onChange={e => setNewColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border border-[var(--border)]"
/>
<button
onClick={handleCreate}
disabled={!newName.trim()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm disabled:opacity-50"
>Создать</button>
</div>
</div>
);
}

View File

@ -3,6 +3,7 @@ import { logout } from "@/lib/auth-client";
const MENU = [ const MENU = [
{ href: "/settings", label: "Общие", icon: "⚙️" }, { href: "/settings", label: "Общие", icon: "⚙️" },
{ href: "/settings/labels", label: "Лейблы", icon: "🏷️" },
{ href: "/settings/agents", label: "Агенты", icon: "🤖" }, { href: "/settings/agents", label: "Агенты", icon: "🤖" },
]; ];