feat(ui): gemini-style top-anchored scroll, centered chat layout and task drawer sync (closes #49)

This commit is contained in:
2026-08-21 12:43:13 +03:00
parent feff469282
commit 7bf1af7c8f
7 changed files with 649 additions and 440 deletions
+84 -22
View File
@@ -229,13 +229,53 @@ async function saveManualPromptDraft(btnEl) {
const highlightedHtml = buildHighlightedPromptHtml(newText, baseline);
previewTextEl.innerHTML = highlightedHtml;
}
let actionButtonsContainer = cardContainer.querySelector(".action-buttons-container");
if (actionButtonsContainer) {
actionButtonsContainer.innerHTML = `
<button type="button" onclick="handleActionButtonClick('подтверждаю')"
class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-check"></i>
<span>Подтвердить</span>
</button>
<button type="button" onclick="handleActionButtonClick('отмена')"
class="bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-xmark"></i>
<span>Отменить</span>
</button>
<button type="button" onclick="openInlinePromptEditor(this)"
class="bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300 font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
<i class="fa-solid fa-pen-to-square text-xs"></i>
<span>✏️ Редактировать</span>
</button>
`;
}
}
} catch (err) {
alert("Ошибка сохранения черновика: " + err);
}
}
// --- [CHAT SEND PIPELINE] ---
// ⭐️ Абсолютный расчет позиции скролла к верху видимой области
function scrollToTurnTop(anchorId) {
const chatWindow = document.getElementById("chat-window");
const anchorEl = document.getElementById(anchorId);
if (!chatWindow || !anchorEl) return;
const windowRect = chatWindow.getBoundingClientRect();
const anchorRect = anchorEl.getBoundingClientRect();
const currentScroll = chatWindow.scrollTop;
// Новая позиция скролла = текущий сдвиг + расстояние от верхней кромки окна до элемента
const targetTop = currentScroll + (anchorRect.top - windowRect.top) - 12;
chatWindow.scrollTo({
top: Math.max(0, targetTop),
behavior: "smooth"
});
}
// --- [CHAT SEND PIPELINE (GEMINI STYLE TOP-SCROLL)] ---
async function sendMessage(e) {
if (e && e.preventDefault) e.preventDefault();
@@ -257,18 +297,29 @@ async function sendMessage(e) {
</div>` + userDisplayHtml;
}
const turnId = "turn-" + Date.now();
const userMsgHtml = `
<div class="flex justify-end mb-3">
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
<div id="${turnId}" class="chat-turn-anchor flex justify-end mb-3 pt-2 scroll-mt-3">
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-xl text-xs sm:text-sm shadow-sm leading-relaxed">
${userDisplayHtml}
</div>
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
// Вставляем вопрос строго перед невидимой распоркой
const spacer = document.getElementById("chat-bottom-spacer");
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", userMsgHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
}
input.value = "";
updateInputHeight(input);
chatWindow.scrollTop = chatWindow.scrollHeight;
// Скроллим сразу при отправке вопроса
setTimeout(() => scrollToTurnTop(turnId), 50);
if (sendBtn) {
sendBtn.disabled = true;
@@ -384,7 +435,6 @@ async function sendMessage(e) {
snapshotsWidgetHtml = renderSnapshotsCard(actionData.data);
}
// ⭐️ БЛОК КАРТОЧКИ СКАЧИВАНИЯ ФАЙЛА
let fileDownloadHtml = "";
if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") {
fileDownloadHtml = `
@@ -409,20 +459,28 @@ async function sendMessage(e) {
const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl";
const botMsgHtml = `
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm ${maxWidthClass} mb-3" data-original-baseline="${escapeHtml(baselineText)}">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
</p>
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
${previewEditorHtml}
${interactiveWidgetHtml}
${snapshotsWidgetHtml}
${fileDownloadHtml}
${actionButtonsHtml}
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full mb-3" data-original-baseline="${escapeHtml(baselineText)}">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
</p>
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
${previewEditorHtml}
${interactiveWidgetHtml}
${snapshotsWidgetHtml}
${fileDownloadHtml}
${actionButtonsHtml}
</div>
`;
// Вставляем карточку ответа строго перед невидимой распоркой
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", botMsgHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
}
// Скроллим к началу хода после рендера карточки ответа
setTimeout(() => scrollToTurnTop(turnId), 100);
clearAttachedFile();
@@ -437,8 +495,12 @@ async function sendMessage(e) {
Ошибка связи с сервером.
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
if (spacer) {
spacer.insertAdjacentHTML("beforebegin", errorHtml);
} else {
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
}
setTimeout(() => scrollToTurnTop(turnId), 50);
setInputLocked(false);
} finally {
const inputEl = document.getElementById("user-input");
+84 -131
View File
@@ -1,164 +1,117 @@
/**
===============================================================================
FILE: modules/web_api/static/js/tasks.js
ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек.
AI-CONTEXT-ANCHORS:
- ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели.
- ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED).
- ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks.
- ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню.
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / static / js
ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer).
===============================================================================
*/
let currentFilter = 'ALL';
let allTasks = [];
let currentTaskFilter = 'IN_PROGRESS';
// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE]
function toggleDrawer() {
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
const drawer = document.getElementById("task-drawer");
const backdrop = document.getElementById("drawer-backdrop");
if (!drawer) return;
const isHidden = drawer.classList.contains("translate-x-full");
if (isHidden) {
drawer.classList.remove("translate-x-full");
if (backdrop) backdrop.classList.remove("hidden");
loadTasks();
} else {
drawer.classList.add("translate-x-full");
if (backdrop) backdrop.classList.add("hidden");
}
}
// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER]
function setFilter(status) {
currentFilter = status;
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
const btn = document.getElementById(`filter-${f}`);
if (btn) {
btn.className = (f === status)
? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
: "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
}
});
renderTasks();
}
// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH]
async function loadTasks() {
const badge = document.getElementById("task-count-badge");
const container = document.getElementById("tasks-container");
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
if (isGuest || !token) {
if (badge) badge.innerText = "0";
return;
}
if (!token) return;
try {
const res = await fetch("/api/v1/tasks", {
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
}
headers: { "Authorization": "Bearer " + token }
});
if (res.status === 401) {
if (typeof logout === 'function') logout();
return;
}
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
if (Array.isArray(data)) {
allTasks = data;
} else if (data && Array.isArray(data.tasks)) {
allTasks = data.tasks;
} else if (data && typeof data === 'object') {
allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
if (res.ok) {
const data = await res.json();
renderSidebarTasks(data.tasks || []);
} else {
allTasks = [];
}
if (badge) {
badge.innerText = allTasks.length.toString();
}
renderTasks();
} catch (err) {
console.error("[Tasks Error]", err);
if (badge) badge.innerText = "0";
if (container) {
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
renderSidebarError("Ошибка доступа. Авторизуйтесь снова.");
}
} catch (e) {
console.error("Ошибка загрузки задач:", e);
renderSidebarError("Ошибка сети. Сервер недоступен.");
}
}
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
function renderTasks() {
const container = document.getElementById("tasks-container");
function renderSidebarError(msg) {
// Поддерживаем оба варианта ID (новый и старый) для обратной совместимости
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
if (container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-rose-500 font-semibold">${msg}</div>`;
}
}
function filterTasksByTab(status) {
currentTaskFilter = status;
// Сброс стилей всех кнопок-вкладок
document.querySelectorAll('.task-tab-btn').forEach(btn => {
btn.classList.remove('text-indigo-600', 'bg-indigo-50');
btn.classList.add('text-slate-600');
});
// Установка активного стиля для выбранной вкладки
const activeBtnId = {
'IN_PROGRESS': 'tab-in-progress',
'BACKLOG': 'tab-backlog',
'COMPLETED': 'tab-completed',
'ALL': 'tab-all'
}[status];
if (activeBtnId) {
const btn = document.getElementById(activeBtnId);
if (btn) {
btn.classList.remove('text-slate-600', 'hover:bg-slate-100');
btn.classList.add('text-indigo-600', 'bg-indigo-50');
}
}
loadTasks();
}
function renderSidebarTasks(tasks) {
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
if (!container) return;
if (!Array.isArray(allTasks)) {
allTasks = [];
let filtered = tasks;
if (currentTaskFilter !== 'ALL') {
filtered = tasks.filter(t => t.status === currentTaskFilter);
}
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
container.innerHTML = `<div class="text-center py-8 text-[11px] font-medium text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-200">Нет задач в этой категории</div>`;
return;
}
container.innerHTML = filtered.map(t => {
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
let statusLabel = "В планах";
let cardBg = "bg-white";
const isCompleted = t.status === 'COMPLETED';
const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL'
? 'text-rose-600 bg-rose-50 border-rose-200'
: t.priority === 'MEDIUM'
? 'text-amber-600 bg-amber-50 border-amber-200'
: 'text-slate-600 bg-slate-50 border-slate-200';
if (t.status === "COMPLETED") {
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
statusLabel = "Завершено";
cardBg = "bg-emerald-50/20";
} else if (t.status === "IN_PROGRESS") {
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
statusLabel = "В работе";
cardBg = "bg-amber-50/20 border-amber-200";
}
let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
let dueDateHtml = t.due_date ? `
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
<i class="fa-solid fa-clock text-amber-600"></i>
<span>Срок: ${t.due_date}</span>
</div>` : "";
return `
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center gap-1.5">
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span>
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
</div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-indigo-400 hover:shadow-md transition cursor-pointer flex flex-col gap-2 group"
onclick="handleActionButtonClick('покажи задачу ${t.id}')">
<div class="flex items-start justify-between gap-2">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider group-hover:text-indigo-500 transition">#${t.id}</span>
${isCompleted
? `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md bg-emerald-50 text-emerald-600 border border-emerald-200 shadow-sm"><i class="fa-solid fa-check mr-0.5"></i> Готово</span>`
: `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md ${priorityColor} shadow-sm">${t.priority || 'LOW'}</span>`
}
</div>
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
<i class="fa-solid fa-folder-closed text-slate-300"></i>
<span>${t.module || 'General'}</span>
<div class="text-xs font-semibold text-slate-700 leading-snug line-clamp-3">${escapeHtml(t.title || 'Без названия')}</div>
<div class="flex items-center justify-between text-[10px] text-slate-400 mt-1">
<span class="bg-slate-100 px-1.5 py-0.5 rounded font-mono truncate max-w-[120px]">${escapeHtml(t.module || 'general')}</span>
${t.due_date ? `<span class="shrink-0 font-medium text-slate-500"><i class="fa-regular fa-calendar mr-1"></i>${escapeHtml(t.due_date)}</span>` : ''}
</div>
${dueDateHtml}
</div>
`;
}).join("");
}
}).join('');
}
// Глобальная инициализация при загрузке DOM
document.addEventListener("DOMContentLoaded", () => {
// Небольшая задержка, чтобы гарантировать применение токена
setTimeout(loadTasks, 200);
});