первй коммит

This commit is contained in:
2026-02-27 21:26:26 +03:00
parent e400b44732
commit ca00e6bbc8
19 changed files with 2701 additions and 257 deletions

142
AGENT_PROPOSAL.md Normal file
View File

@@ -0,0 +1,142 @@
# 🚀 Readme File Overhaul — AI Project Editor Frontend
## Overview
This is the **Readme** document for the **Web MVP: AI Project Editor**, a cutting-edge front-end application designed to streamline project management and collaboration with AI-driven tools. The goal of this project is to provide users with an intuitive interface that integrates seamlessly with our backend system while offering powerful features such as file browsing, collaborative editing, and AI-assisted task tracking.
---
## Table of Contents
- [Getting Started](#getting-started)
- [Key Features](#key-features)
- [Project Structure](#project-structure)
- [Technical Details](#technical-details)
- [Installation & Setup](#installation-and-setup)
- [Contributing](#contributing)
- [Acknowledgments](#acknowledgments)
---
## Getting Started
### Prerequisites
Before you begin, ensure your environment meets these requirements:
- Node.js v14.x or higher
- Docker Compose installed on your machine
### Installation & Setup
To set up the development environment:
1. Clone the repository:
```bash
git clone https://github.com/your-repo.git
```
2. Navigate into the project folder:
```bash
cd ai-project-editor-frontend
```
3. Install dependencies using npm:
```bash
npm install
```
4. Start the local server:
```bash
docker-compose up --build
```
5. Open the frontend in your browser at:
- `http://localhost:8080`
### Running Backend Locally
The backend is also available via Docker Compose. To run it alongside the frontend:
1. Change directories to the backend location:
```bash
cd /path/to/backend-directory
```
2. Run the following command:
```bash
docker-compose up --build
```
3. Access the backend service at:
- `http://localhost:8081`
---
## Key Features
### Core Functionality
- **File Browsing**: Securely access files from user's local directory through `showDirectoryPicker` or fallback `input[webkitdirectory]`. Directories like `.git` are excluded automatically.
- **Recursive Tree View**: Recursively display folders and their contents, allowing users to navigate deep hierarchies easily.
- **Markdown Editing**: Create new Markdown files (.md) directly within the interface by clicking the "+MD" button.
- **Collaborative Review System**: Users can track changes made to documents, view diffs, and approve/reject individual files or sections.
- **Responsive Layout**: Three-column layout with default widths: 15%, 65%, and 20%.
- **Integration with Backend Services**: Real-time communication between frontend and backend includes:
- Post requests to `/api/rag/sessions` and polling for updates using `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`.
- Chat dialogues via `POST /api/chat/dialogs` and messages via `POST /api/chat/messages` with real-time polled responses.
- **Validation**: Ensure all required fields in changesets are validated before processing ("create", "update", "delete").
- **Review Status Management**: Track review statuses including `pending`, `accepted_partial`, `accepted_full`, `rejected`, `conflict`, and `applied`.
- **Apply Accepted Changes**: Apply approved changes after verifying hashes and confirming deletion.
- **No Git Operations**: No direct interaction with Git repositories; no automatic application of edits.
---
## Technical Details
### Project Structure
The project is organized into several key components:
- **Frontend**: Built using Monaco Editor, React, and CSS modules.
- **Backend**: Uses RESTful APIs powered by Node.js and Express.
- **Database**: Stores user data and session information in MongoDB.
### Tools Used
- **Monaco Editor**: Powerful JavaScript-based editor embedded within the application.
- **React**: For building dynamic interfaces and managing state.
- **MongoDB**: Manages persistent storage for user sessions and configurations.
- **Docker Compose**: Facilitates easy deployment and integration testing.
---
## Contributing
If you'd like to contribute to this project, follow these steps:
1. Fork the repository.
2. Create a new branch for your feature or bug fix.
3. Make necessary changes and submit a pull request.
4. Discuss any major changes with the team beforehand.
---
## Acknowledgments
Special thanks to the developers who contributed to this project, including:
- Alex Semenov
- Codex 2026.02.23
- Team members
This project would not be possible without their dedication and expertise!

View File

@@ -4,7 +4,7 @@
Frontend подключен к реальному backend API (без mock-клиентов). Frontend подключен к реальному backend API (без mock-клиентов).
По умолчанию используется `http://localhost:8081`. По умолчанию используется `http://localhost:15000`.
## Что реализовано ## Что реализовано
@@ -54,4 +54,3 @@ docker compose up --build
новые изменения новые изменения

View File

@@ -35,22 +35,16 @@
</div> </div>
<div class="row-controls tabs-row"> <div class="row-controls tabs-row">
<div id="file-tabs" class="tabs"></div> <div id="file-tabs" class="tabs"></div>
<button id="new-text-tab" type="button" class="new-tab-btn" title="Новая вкладка .md">+MD</button>
<button id="md-toggle-mode" type="button" class="md-toggle" title="Режим markdown" disabled>👁</button> <button id="md-toggle-mode" type="button" class="md-toggle" title="Режим markdown" disabled>👁</button>
</div> </div>
<div class="editor-workspace"> <div class="editor-workspace">
<div id="editor-empty-state" class="editor-empty-state">Откройте файл в дереве проекта</div>
<textarea id="file-editor" class="file-editor large" spellcheck="false"></textarea> <textarea id="file-editor" class="file-editor large" spellcheck="false"></textarea>
<div id="file-editor-monaco" class="file-editor monaco-host large hidden"></div> <div id="file-editor-monaco" class="file-editor monaco-host large hidden"></div>
<div id="md-preview" class="md-preview large hidden"></div> <div id="md-preview" class="md-preview large hidden"></div>
<div class="review-wrap editor-review hidden"> <div class="review-wrap editor-review hidden">
<h2>Ревью изменений</h2> <h2>Ревью изменений</h2>
<div id="review-toolbar" class="toolbar hidden"> <div id="review-toolbar" class="toolbar hidden"></div>
<button id="accept-file">Accept file</button>
<button id="reject-file">Reject file</button>
<button id="accept-selected">Accept selected</button>
<button id="reject-selected">Reject selected</button>
<button id="apply-accepted">Apply accepted</button>
</div>
<div id="change-list" class="change-list"></div> <div id="change-list" class="change-list"></div>
<div id="diff-view" class="diff-view"></div> <div id="diff-view" class="diff-view"></div>
</div> </div>
@@ -82,6 +76,12 @@
</div> </div>
</section> </section>
</main> </main>
<div id="tree-context-menu" class="tree-context-menu hidden" role="menu" aria-label="Действия с файлом">
<button id="tree-menu-create-dir" type="button" role="menuitem">Создать папку</button>
<button id="tree-menu-create" type="button" role="menuitem">Создать файл в папке</button>
<button id="tree-menu-delete" type="button" role="menuitem">Удалить</button>
<button id="tree-menu-rename" type="button" role="menuitem">Переименовать</button>
</div>
<div id="indexing-modal" class="indexing-modal hidden" role="dialog" aria-modal="true" aria-live="polite"> <div id="indexing-modal" class="indexing-modal hidden" role="dialog" aria-modal="true" aria-live="polite">
<div class="indexing-card"> <div class="indexing-card">
<h3>Индексация проекта в RAG</h3> <h3>Индексация проекта в RAG</h3>
@@ -93,9 +93,28 @@
<span class="indexing-label">Осталось:</span> <span class="indexing-label">Осталось:</span>
<span id="indexing-remaining" class="indexing-value"></span> <span id="indexing-remaining" class="indexing-value"></span>
</div> </div>
<div class="indexing-row">
<span class="indexing-label">Всего проиндексировано:</span>
<span id="indexing-total-indexed" class="indexing-value">0</span>
</div>
<div class="indexing-row">
<span class="indexing-label">С кэшем rag_repo:</span>
<span id="indexing-cache-hit" class="indexing-value">0</span>
</div>
<div class="indexing-row">
<span class="indexing-label">Без кэша rag_repo:</span>
<span id="indexing-cache-miss" class="indexing-value">0</span>
</div>
<div class="indexing-row">
<span class="indexing-label">Доля reuse:</span>
<span id="indexing-reuse-ratio" class="indexing-value">0%</span>
</div>
<div class="indexing-progress"> <div class="indexing-progress">
<div id="indexing-progress-bar" class="indexing-progress-bar"></div> <div id="indexing-progress-bar" class="indexing-progress-bar"></div>
</div> </div>
<div class="indexing-actions">
<button id="indexing-close-btn" type="button" disabled>Закрыть</button>
</div>
</div> </div>
</div> </div>

1
new-1.md Normal file
View File

@@ -0,0 +1 @@
тестовый файл

View File

@@ -1,23 +1,115 @@
export class ApiHttpClient { export class ApiHttpClient {
constructor(baseUrl = null) { constructor(baseUrl = null) {
const envBase = window.__API_BASE_URL__ || null; const envBase = window.__API_BASE_URL__ || null;
this.baseUrl = (baseUrl || envBase || "http://localhost:8081").replace(/\/$/, ""); const resolved = (baseUrl || envBase || "http://localhost:15000").replace(/\/$/, "");
this.baseUrl = resolved;
this.autoFallbackEnabled = !baseUrl && !envBase;
this.fallbackBaseUrls = this.#buildFallbackBaseUrls(resolved);
} }
async request(path, options = {}) { async request(path, options = {}) {
const response = await fetch(`${this.baseUrl}${path}`, { const attempted = new Set();
let lastError = null;
const candidates = [this.baseUrl, ...this.fallbackBaseUrls];
const method = String(options.method || "GET").toUpperCase();
for (const base of candidates) {
if (!base || attempted.has(base)) continue;
attempted.add(base);
try {
const response = await fetch(`${base}${path}`, {
...options, ...options,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(options.headers || {}) ...(options.headers || {})
} }
}); });
const isJson = (response.headers.get("content-type") || "").includes("application/json"); const body = await this.#readResponseBody(response);
const body = isJson ? await response.json() : null;
if (!response.ok) { if (!response.ok) {
const desc = body?.desc || body?.detail || `HTTP ${response.status}`; const backendMessage = this.#extractBackendMessage(body);
throw new Error(desc); const fallbackMessage = `HTTP ${response.status}`;
const error = new Error(backendMessage || fallbackMessage);
error.name = "ApiHttpError";
error.status = response.status;
error.path = path;
error.method = method;
error.responseBody = body;
error.backendMessage = backendMessage || "";
throw error;
}
if (base !== this.baseUrl) {
this.baseUrl = base;
this.fallbackBaseUrls = this.#buildFallbackBaseUrls(base);
} }
return body; return body;
} catch (error) {
lastError = error;
if (!this.autoFallbackEnabled || !this.#isNetworkError(error)) {
throw error;
}
}
}
throw lastError || new Error("HTTP request failed");
}
resolveUrl(path) {
return `${this.baseUrl}${path}`;
}
#isNetworkError(error) {
const message = String(error?.message || "");
return (
message.includes("Failed to fetch") ||
message.includes("NetworkError") ||
message.includes("Load failed") ||
message.includes("fetch")
);
}
#buildFallbackBaseUrls(currentBase) {
if (!this.autoFallbackEnabled) return [];
let parsed;
try {
parsed = new URL(currentBase);
} catch {
return [];
}
const host = parsed.hostname || "localhost";
const protocol = parsed.protocol || "http:";
const preferredPorts = ["15000", "8081", "8000"];
return preferredPorts
.filter((port) => port !== parsed.port)
.map((port) => `${protocol}//${host}:${port}`);
}
async #readResponseBody(response) {
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (contentType.includes("application/json")) {
try {
return await response.json();
} catch {
return null;
}
}
const text = await response.text();
const trimmed = String(text || "").trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return { detail: trimmed };
}
}
#extractBackendMessage(body) {
if (!body || typeof body !== "object") return "";
const message =
body?.error?.desc ||
body?.error?.message ||
body?.desc ||
body?.detail ||
body?.message ||
"";
return typeof message === "string" ? message.trim() : "";
} }
} }

View File

@@ -5,9 +5,10 @@ export class ApplyEngine {
this.hashService = hashService; this.hashService = hashService;
} }
async applyAccepted(projectStore, reviewStore, changeMap) { async applyAccepted(projectStore, reviewStore, changeMap, onlyPaths = null) {
const changedFiles = []; const changedFiles = [];
for (const path of reviewStore.acceptedPaths()) { const paths = Array.isArray(onlyPaths) ? onlyPaths : reviewStore.acceptedPaths();
for (const path of paths) {
const change = changeMap.get(path); const change = changeMap.get(path);
const review = reviewStore.get(path); const review = reviewStore.get(path);
if (!change || !review) continue; if (!change || !review) continue;
@@ -18,9 +19,7 @@ export class ApplyEngine {
continue; continue;
} }
if (change.op === "delete") { if (change.op === "delete" && review.status === "accepted_full") {
const confirmed = window.confirm(`Удалить файл ${path}?`);
if (!confirmed) continue;
await this.#deleteFile(projectStore.rootHandle, path); await this.#deleteFile(projectStore.rootHandle, path);
projectStore.removeFile(path); projectStore.removeFile(path);
reviewStore.markApplied(path); reviewStore.markApplied(path);
@@ -39,25 +38,23 @@ export class ApplyEngine {
} }
#composeContent(change, review, currentContent) { #composeContent(change, review, currentContent) {
if (review.status === "rejected") return currentContent;
if (review.status === "accepted_full") return change.proposed_content; if (review.status === "accepted_full") return change.proposed_content;
if (change.op === "create") return change.proposed_content;
const localLines = currentContent.replace(/\r\n/g, "\n").split("\n");
const output = []; const output = [];
for (const op of change.diffOps) { for (const op of change.diffOps) {
const accepted = review.acceptedOpIds.has(op.id); const accepted = review.acceptedBlockIds.has(op.blockId);
if (op.kind === "equal") output.push(op.oldLine); if (op.kind === "equal") output.push(op.oldLine);
else if (op.kind === "add" && accepted) output.push(op.newLine); else if (op.kind === "add" && accepted) output.push(op.newLine);
else if (op.kind === "remove" && !accepted) output.push(op.oldLine); else if (op.kind === "remove" && !accepted) output.push(op.oldLine);
} }
const merged = output.join("\n"); const merged = output.join("\n");
if (!merged.length) return localLines.join("\n"); return merged.length ? merged : currentContent;
return merged;
} }
async #checkConflict(projectStore, change) { async #checkConflict(projectStore, change) {
const file = projectStore.files.get(change.path); const file = projectStore.getFile(change.path);
if (change.op === "create") { if (change.op === "create") {
return { ok: !file, currentContent: "" }; return { ok: !file, currentContent: "" };

View File

@@ -1,10 +1,12 @@
import { ApiHttpClient } from "./ApiHttpClient.js"; import { ApiHttpClient } from "./ApiHttpClient.js";
import { TaskEventsSseClient } from "./TaskEventsSseClient.js";
export class ChatClientApi { export class ChatClientApi {
constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000) { constructor(http = new ApiHttpClient(), pollMs = 700, timeoutMs = 120000, events = null) {
this.http = http; this.http = http;
this.pollMs = pollMs; this.pollMs = pollMs;
this.timeoutMs = timeoutMs; this.timeoutMs = timeoutMs;
this.events = events || new TaskEventsSseClient(this.http);
} }
async createDialog(ragSessionId) { async createDialog(ragSessionId) {
@@ -15,30 +17,118 @@ export class ChatClientApi {
return response.dialog_session_id; return response.dialog_session_id;
} }
async sendMessage(payload) { async sendMessage(payload, handlers = {}) {
const queued = await this.http.request("/api/chat/messages", { const queued = await this.http.request("/api/chat/messages", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
dialog_session_id: payload.dialog_session_id, dialog_session_id: payload.dialog_session_id,
rag_session_id: payload.rag_session_id, rag_session_id: payload.rag_session_id,
message: payload.message, message: payload.message,
attachments: payload.attachments || [] attachments: payload.attachments || [],
mode: payload.mode || "auto",
files: payload.files || []
}) })
}); });
const taskId = queued.task_id; const taskId = queued.task_id;
const onEvent = typeof handlers.onEvent === "function" ? handlers.onEvent : null;
if (onEvent) onEvent({ kind: "queued", task_id: taskId });
const sse = this.events.open(taskId, onEvent);
try {
const firstResult = await Promise.race([
this.#pollTask(taskId, onEvent).then((payload) => ({ winner: "poll", payload })),
sse.terminal.then((payload) => ({ winner: "sse", payload }))
]);
let finalPayload = firstResult?.payload;
if (firstResult?.winner === "poll") {
const sseTerminal = await this.#awaitWithTimeout(sse.terminal, 1200);
if (sseTerminal?.kind === "result" || sseTerminal?.kind === "error") finalPayload = sseTerminal;
}
if (finalPayload?.kind === "error") throw new Error(finalPayload.message || "Task failed");
return this.#normalizeFinalResult(finalPayload, taskId);
} finally {
await sse.close();
}
}
async #pollTask(taskId, onEvent = null) {
const started = Date.now(); const started = Date.now();
while (Date.now() - started < this.timeoutMs) { while (Date.now() - started < this.timeoutMs) {
const status = await this.http.request(`/api/tasks/${encodeURIComponent(taskId)}`); const status = await this.http.request(`/api/tasks/${encodeURIComponent(taskId)}`);
const event = this.#normalizeStatusEvent(status, taskId);
if (event && onEvent) onEvent(event);
if (status.status === "done") return status; if (status.status === "done") return status;
if (status.status === "error") { if (status.status === "error") {
throw new Error(status.error?.desc || "Task failed"); throw new Error(status.error?.desc || status.error?.message || "Task failed");
} }
await this.#sleep(this.pollMs); await this.#sleep(this.pollMs);
} }
throw new Error("Task polling timeout"); throw new Error("Task polling timeout");
} }
#normalizeStatusEvent(status, taskId) {
if (!status || typeof status !== "object") return null;
if (status.status === "done") {
const normalized = this.#normalizeFinalResult(status, taskId);
return { kind: "result", ...normalized };
}
if (status.status === "error") {
const message = status.error?.desc || status.error?.message || status.error || status.message || "Task failed";
return { kind: "error", task_id: taskId, message: String(message) };
}
return {
kind: "status",
task_id: status.task_id || taskId,
status: status.status || "in_progress",
stage: status.stage || "",
message: status.message || "",
meta: status.meta || {}
};
}
#normalizeFinalResult(payload, taskId) {
if (payload?.kind === "result") {
const resultType = payload.result_type || (Array.isArray(payload.changeset) ? "changeset" : "answer");
return {
task_id: payload.task_id || taskId,
status: payload.status || "done",
result_type: resultType,
answer: payload.answer || "",
changeset: Array.isArray(payload.changeset) ? payload.changeset : [],
meta: payload.meta || {}
};
}
const src = payload && typeof payload === "object" ? payload : {};
const resultContainer = src.result && typeof src.result === "object" ? src.result : src;
const resultType = resultContainer.result_type || (Array.isArray(resultContainer.changeset) ? "changeset" : "answer");
return {
task_id: src.task_id || taskId,
status: src.status || "done",
result_type: resultType,
answer: resultContainer.answer || "",
changeset: Array.isArray(resultContainer.changeset) ? resultContainer.changeset : [],
meta: resultContainer.meta || src.meta || {}
};
}
async #awaitWithTimeout(promise, timeoutMs) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
})
]);
} finally {
if (timer) clearTimeout(timer);
}
}
#sleep(ms) { #sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }

View File

@@ -0,0 +1,58 @@
export class ErrorMessageFormatter {
buildActionMessage(action, error, fallbackProblem) {
const backendMessage = this.extractBackendMessage(error);
if (backendMessage) return backendMessage;
const explicitMessage = this.extractExplicitMessage(error);
if (explicitMessage) return explicitMessage;
const safeAction = String(action || "Не удалось выполнить действие").trim();
const safeProblem = String(fallbackProblem || "возникла ошибка").trim();
return `${safeAction}: ${safeProblem}.`;
}
extractBackendMessage(error) {
const fromDirect = this.#pickMessage(error?.backendMessage);
if (fromDirect) return fromDirect;
const body = error?.responseBody;
if (!body || typeof body !== "object") return "";
return (
this.#pickMessage(body?.error?.desc) ||
this.#pickMessage(body?.error?.message) ||
this.#pickMessage(body?.desc) ||
this.#pickMessage(body?.detail) ||
this.#pickMessage(body?.message) ||
""
);
}
extractExplicitMessage(error) {
const message = this.#pickMessage(error?.message);
if (!message) return "";
if (this.#isTechnicalTransportMessage(message)) return "";
return message;
}
#pickMessage(value) {
if (typeof value !== "string") return "";
const text = value.trim();
return text || "";
}
#isTechnicalTransportMessage(message) {
const text = String(message || "").trim();
if (!text) return true;
return (
/^HTTP\s+\d{3}$/i.test(text) ||
/failed to fetch/i.test(text) ||
/networkerror/i.test(text) ||
/load failed/i.test(text) ||
/task polling timeout/i.test(text) ||
/index polling timeout/i.test(text) ||
/^task failed$/i.test(text) ||
/^indexing failed$/i.test(text) ||
/^sse /i.test(text)
);
}
}

View File

@@ -7,47 +7,49 @@ export class FileSaveService {
async saveFile(projectStore, path, content) { async saveFile(projectStore, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path); const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (projectStore.rootHandle) { if (projectStore.rootHandle) {
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content); await this.#writeWithRootHandle(projectStore.rootHandle, resolvedPath, content);
return { mode: "inplace", path: normalizedPath }; return { mode: "inplace", path: resolvedPath };
} }
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath); const knownHandle = projectStore.getFileHandle(resolvedPath) || this.fallbackHandles.get(resolvedPath);
if (knownHandle && typeof knownHandle.createWritable === "function") { if (knownHandle && typeof knownHandle.createWritable === "function") {
await this.#writeWithFileHandle(knownHandle, content); await this.#writeWithFileHandle(knownHandle, content);
return { mode: "inplace", path: normalizedPath }; return { mode: "inplace", path: resolvedPath };
} }
if (typeof window.showSaveFilePicker === "function") { if (typeof window.showSaveFilePicker === "function") {
const pickerOptions = { const pickerOptions = {
suggestedName: PathUtils.basename(normalizedPath), suggestedName: PathUtils.basename(resolvedPath),
id: this.#buildProjectSaveId(projectStore) id: this.#buildProjectSaveId(projectStore)
}; };
const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(normalizedPath); const startInHandle = projectStore.rootHandle || knownHandle || this.fallbackHandles.get(resolvedPath);
if (startInHandle) pickerOptions.startIn = startInHandle; if (startInHandle) pickerOptions.startIn = startInHandle;
const handle = await window.showSaveFilePicker(pickerOptions); const handle = await window.showSaveFilePicker(pickerOptions);
await this.#writeWithFileHandle(handle, content); await this.#writeWithFileHandle(handle, content);
this.fallbackHandles.set(normalizedPath, handle); this.fallbackHandles.set(resolvedPath, handle);
return { mode: "save_as", path: normalizedPath }; return { mode: "save_as", path: resolvedPath };
} }
this.#downloadFile(normalizedPath, content); this.#downloadFile(resolvedPath, content);
return { mode: "download", path: normalizedPath }; return { mode: "download", path: resolvedPath };
} }
async saveExistingFile(projectStore, path, content) { async saveExistingFile(projectStore, path, content) {
const normalizedPath = PathUtils.normalizeRelative(path); const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (projectStore.rootHandle) { if (projectStore.rootHandle) {
await this.#writeWithRootHandle(projectStore.rootHandle, normalizedPath, content); await this.#writeWithRootHandle(projectStore.rootHandle, resolvedPath, content);
return { mode: "inplace", path: normalizedPath }; return { mode: "inplace", path: resolvedPath };
} }
const knownHandle = projectStore.fileHandles.get(normalizedPath) || this.fallbackHandles.get(normalizedPath); const knownHandle = projectStore.getFileHandle(resolvedPath) || this.fallbackHandles.get(resolvedPath);
if (knownHandle && typeof knownHandle.createWritable === "function") { if (knownHandle && typeof knownHandle.createWritable === "function") {
await this.#writeWithFileHandle(knownHandle, content); await this.#writeWithFileHandle(knownHandle, content);
return { mode: "inplace", path: normalizedPath }; return { mode: "inplace", path: resolvedPath };
} }
throw new Error("Нет доступа к существующему файлу для записи без выбора новой директории."); throw new Error("Нет доступа к существующему файлу для записи без выбора новой директории.");
@@ -78,8 +80,9 @@ export class FileSaveService {
async deleteFile(projectStore, path) { async deleteFile(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path); const normalizedPath = PathUtils.normalizeRelative(path);
const resolvedPath = projectStore.resolveFilePath(normalizedPath) || normalizedPath;
if (!projectStore.rootHandle) return false; if (!projectStore.rootHandle) return false;
const parts = normalizedPath.split("/"); const parts = resolvedPath.split("/");
const fileName = parts.pop(); const fileName = parts.pop();
let dir = projectStore.rootHandle; let dir = projectStore.rootHandle;
@@ -91,6 +94,51 @@ export class FileSaveService {
return true; return true;
} }
async createDirectory(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
const parts = normalizedPath.split("/");
let dir = projectStore.rootHandle;
for (const part of parts) {
dir = await dir.getDirectoryHandle(part, { create: true });
}
return true;
}
async deleteDirectory(projectStore, path) {
const normalizedPath = PathUtils.normalizeRelative(path);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
const parts = normalizedPath.split("/");
const dirName = parts.pop();
let parent = projectStore.rootHandle;
for (const part of parts) {
parent = await parent.getDirectoryHandle(part);
}
await parent.removeEntry(dirName, { recursive: true });
return true;
}
async renameDirectory(projectStore, oldPath, newPath) {
const normalizedOld = PathUtils.normalizeRelative(oldPath);
const normalizedNew = PathUtils.normalizeRelative(newPath);
if (!projectStore.rootHandle) throw new Error("Нет доступа к директории проекта.");
if (normalizedOld === normalizedNew) return true;
if (normalizedNew.startsWith(`${normalizedOld}/`)) {
throw new Error("Нельзя переместить папку внутрь самой себя.");
}
const sourceDir = await this.#getDirectoryHandleByPath(projectStore.rootHandle, normalizedOld, false);
const newParts = normalizedNew.split("/");
const newName = newParts.pop();
const newParentPath = newParts.join("/");
const targetParent = await this.#getDirectoryHandleByPath(projectStore.rootHandle, newParentPath, true);
const targetDir = await targetParent.getDirectoryHandle(newName, { create: true });
await this.#copyDirectoryRecursive(sourceDir, targetDir);
await this.deleteDirectory(projectStore, normalizedOld);
return true;
}
async #writeWithRootHandle(rootHandle, path, content) { async #writeWithRootHandle(rootHandle, path, content) {
const parts = path.split("/"); const parts = path.split("/");
const fileName = parts.pop(); const fileName = parts.pop();
@@ -104,6 +152,32 @@ export class FileSaveService {
await this.#writeWithFileHandle(handle, content); await this.#writeWithFileHandle(handle, content);
} }
async #getDirectoryHandleByPath(rootHandle, path, createMissing) {
if (!path) return rootHandle;
let dir = rootHandle;
const parts = path.split("/").filter(Boolean);
for (const part of parts) {
dir = await dir.getDirectoryHandle(part, { create: createMissing });
}
return dir;
}
async #copyDirectoryRecursive(sourceDir, targetDir) {
for await (const [entryName, entryHandle] of sourceDir.entries()) {
if (entryHandle.kind === "directory") {
const childTarget = await targetDir.getDirectoryHandle(entryName, { create: true });
await this.#copyDirectoryRecursive(entryHandle, childTarget);
continue;
}
const sourceFile = await entryHandle.getFile();
const targetFile = await targetDir.getFileHandle(entryName, { create: true });
const writable = await targetFile.createWritable();
await writable.write(sourceFile);
await writable.close();
}
}
async #writeWithFileHandle(handle, content) { async #writeWithFileHandle(handle, content) {
const writable = await handle.createWritable(); const writable = await handle.createWritable();
await writable.write(content); await writable.write(content);

View File

@@ -5,42 +5,389 @@ export class IndexingClientApi {
this.http = http; this.http = http;
this.pollMs = pollMs; this.pollMs = pollMs;
this.timeoutMs = timeoutMs; this.timeoutMs = timeoutMs;
this.sseReconnectAttempts = 5;
this.sseReconnectDelayMs = 1000;
this.sseActivityGraceMs = 15000;
} }
async submitSnapshot(projectId, files) { async submitSnapshot(projectId, files, onProgress = null) {
const queued = await this.http.request("/api/rag/sessions", { const queued = await this.http.request("/api/rag/sessions", {
method: "POST", method: "POST",
body: JSON.stringify({ project_id: projectId, files }) body: JSON.stringify({ project_id: projectId, files })
}); });
const status = await this.#pollRagJob(queued.rag_session_id, queued.index_job_id); const status = await this.#waitForRagJob(queued.rag_session_id, queued.index_job_id, onProgress);
return { ...status, rag_session_id: queued.rag_session_id }; return { ...status, rag_session_id: queued.rag_session_id };
} }
async submitChanges(ragSessionId, changedFiles) { async submitChanges(ragSessionId, changedFiles, onProgress = null) {
const queued = await this.http.request(`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/changes`, { const queued = await this.http.request(`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/changes`, {
method: "POST", method: "POST",
body: JSON.stringify({ changed_files: changedFiles }) body: JSON.stringify({ changed_files: changedFiles })
}); });
const status = await this.#pollRagJob(ragSessionId, queued.index_job_id); const status = await this.#waitForRagJob(ragSessionId, queued.index_job_id, onProgress);
return { ...status, rag_session_id: ragSessionId }; return { ...status, rag_session_id: ragSessionId };
} }
async #pollRagJob(ragSessionId, jobId) { async #waitForRagJob(ragSessionId, jobId, onProgress) {
const started = Date.now(); const progressState = { done: null, total: null, failed: null, cacheHitFiles: null, cacheMissFiles: null, lastSseActivityAt: 0 };
while (Date.now() - started < this.timeoutMs) { const handleProgress = (event) => {
const status = await this.http.request( const done = this.#toNumber(event?.done);
const total = this.#toNumber(event?.total);
const failed = this.#toNumber(event?.failed);
const cacheHitFiles = this.#toNumber(event?.cacheHitFiles);
const cacheMissFiles = this.#toNumber(event?.cacheMissFiles);
if (event?.source === "sse") progressState.lastSseActivityAt = Date.now();
if (Number.isFinite(done)) progressState.done = done;
if (Number.isFinite(total)) progressState.total = total;
if (Number.isFinite(failed)) progressState.failed = failed;
if (Number.isFinite(cacheHitFiles)) progressState.cacheHitFiles = cacheHitFiles;
if (Number.isFinite(cacheMissFiles)) progressState.cacheMissFiles = cacheMissFiles;
if (typeof onProgress === "function") onProgress(event);
};
const sse = this.#openSseProgress(ragSessionId, jobId, handleProgress);
try {
const firstResult = await Promise.race([
this.#pollRagJob(
ragSessionId,
jobId,
handleProgress,
() => this.#hasRecentSseActivity(progressState.lastSseActivityAt)
).then((payload) => ({ winner: "poll", payload })),
sse.terminal.then((payload) => ({ winner: "sse", payload }))
]);
let result = firstResult?.payload;
// If poll finished first, give SSE a short grace window to deliver terminal/progress events.
if (firstResult?.winner === "poll") {
const sseResult = await this.#awaitWithTimeout(sse.terminal, 1500);
if (sseResult?.status === "done") {
result = sseResult;
} else if (sseResult?.status === "error" && result?.status !== "done") {
result = sseResult;
}
}
if (result?.status === "error") {
throw new Error(result.error?.desc || result.error || "Indexing failed");
}
if (result?.status === "done") {
return {
...result,
indexed_files: this.#toNumber(result.indexed_files) ?? this.#toNumber(result.done) ?? progressState.done ?? 0,
failed_files: this.#toNumber(result.failed_files) ?? this.#toNumber(result.failed) ?? progressState.failed ?? 0,
cache_hit_files:
this.#toNumber(result.cache_hit_files) ?? this.#toNumber(result.cacheHitFiles) ?? progressState.cacheHitFiles ?? 0,
cache_miss_files:
this.#toNumber(result.cache_miss_files) ?? this.#toNumber(result.cacheMissFiles) ?? progressState.cacheMissFiles ?? 0
};
}
return result || {
status: "done",
indexed_files: progressState.done ?? 0,
failed_files: progressState.failed ?? 0,
cache_hit_files: progressState.cacheHitFiles ?? 0,
cache_miss_files: progressState.cacheMissFiles ?? 0
};
} catch (error) {
if (this.#isProgressComplete(progressState)) {
return {
status: "done",
indexed_files: progressState.done ?? 0,
failed_files: progressState.failed ?? 0,
cache_hit_files: progressState.cacheHitFiles ?? 0,
cache_miss_files: progressState.cacheMissFiles ?? 0
};
}
throw error;
} finally {
await sse.close();
}
}
#openSseProgress(ragSessionId, jobId, onProgress) {
const controller = new AbortController();
let terminalResolved = false;
let resolveTerminal;
const terminal = new Promise((resolve) => {
resolveTerminal = resolve;
});
const finishTerminal = (payload) => {
if (terminalResolved) return;
terminalResolved = true;
resolveTerminal(payload);
};
const task = this.#streamRagEventsWithReconnect(
ragSessionId,
jobId,
onProgress,
controller.signal,
finishTerminal,
() => terminalResolved
)
.catch((error) => {
const message = String(error?.message || error || "");
const abortedByClient = controller.signal.aborted || /abort/i.test(message);
if (abortedByClient) return;
finishTerminal({ status: "error", error: message || "SSE stream error" });
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE stream error: ${message || "stream_error"}`,
raw: message || "stream_error"
});
}
});
return {
terminal,
close: async () => {
controller.abort();
finishTerminal({ status: "aborted" });
await task;
}
};
}
async #streamRagEventsWithReconnect(ragSessionId, jobId, onProgress, signal, onTerminal, isTerminalResolved) {
let attempts = 0;
while (!signal.aborted) {
try {
const streamResult = await this.#streamRagEvents(ragSessionId, jobId, onProgress, signal, onTerminal);
if (streamResult?.hadEvents) attempts = 0;
if (signal.aborted || isTerminalResolved()) return;
if (attempts >= this.sseReconnectAttempts) {
throw new Error(`SSE stream closed and reconnect limit (${this.sseReconnectAttempts}) reached`);
}
attempts += 1;
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE reconnect attempt ${attempts}/${this.sseReconnectAttempts} after stream close`,
raw: "sse_reconnect"
});
}
} catch (error) {
const message = String(error?.message || error || "");
const abortedByClient = signal.aborted || /abort/i.test(message);
if (abortedByClient || isTerminalResolved()) return;
if (attempts >= this.sseReconnectAttempts) {
throw new Error(`SSE reconnect failed after ${this.sseReconnectAttempts} attempts: ${message || "stream_error"}`);
}
attempts += 1;
if (typeof onProgress === "function") {
onProgress({
status: "progress",
source: "sse",
message: `SSE reconnect attempt ${attempts}/${this.sseReconnectAttempts}: ${message || "stream_error"}`,
raw: message || "sse_reconnect_error"
});
}
}
await this.#sleep(this.sseReconnectDelayMs);
}
}
async #streamRagEvents(ragSessionId, jobId, onProgress, signal, onTerminal) {
const path = `/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}/events?replay=true`;
const response = await fetch(this.http.resolveUrl(path), {
method: "GET",
headers: { Accept: "text/event-stream" },
signal
});
if (!response.ok) throw new Error(`SSE HTTP ${response.status}`);
if (!response.body) throw new Error("SSE stream is empty");
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (!contentType.includes("text/event-stream")) {
throw new Error(`SSE invalid content-type: ${contentType || "unknown"}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let hadEvents = false;
const state = { eventName: "", dataLines: [] };
const dispatch = () => {
if (!state.dataLines.length) return;
const rawData = state.dataLines.join("\n");
state.dataLines = [];
const payload = this.#tryParseJson(rawData);
const normalized = this.#normalizeProgressEvent(payload, rawData, state.eventName);
state.eventName = "";
if (!normalized) return;
hadEvents = true;
normalized.source = "sse";
if (typeof onProgress === "function") onProgress(normalized);
if (normalized.status === "done") {
const indexed = this.#toNumber(
payload?.indexed_files ?? payload?.done ?? payload?.processed_files ?? payload?.completed ?? normalized.done
);
const failed = this.#toNumber(payload?.failed_files ?? payload?.failed ?? normalized.failed);
const cacheHitFiles = this.#toNumber(payload?.cache_hit_files ?? payload?.cacheHitFiles ?? normalized.cacheHitFiles);
const cacheMissFiles = this.#toNumber(payload?.cache_miss_files ?? payload?.cacheMissFiles ?? normalized.cacheMissFiles);
if (typeof onTerminal === "function") {
onTerminal({
status: "done",
indexed_files: indexed ?? 0,
failed_files: failed ?? 0,
total_files: this.#toNumber(payload?.total_files ?? payload?.total ?? normalized.total),
cache_hit_files: cacheHitFiles ?? 0,
cache_miss_files: cacheMissFiles ?? 0
});
}
} else if (normalized.status === "error" && typeof onTerminal === "function") {
onTerminal({
status: "error",
error: payload?.error || payload?.message || normalized.message || "Indexing failed"
});
}
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
dispatch();
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith(":")) {
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith("event:")) {
state.eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
state.dataLines.push(line.slice(5).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
if (state.dataLines.length) dispatch();
return { done: true, hadEvents };
}
#normalizeProgressEvent(payload, rawData, eventName) {
const src = payload && typeof payload === "object" ? payload : {};
const status = this.#normalizeStatus(src.status || src.state || eventName || "");
const stringPayload = typeof payload === "string" ? payload.trim() : "";
const currentFile =
src.current_file ||
src.currentFile ||
src.current_file_path ||
src.current_path ||
src.file_path ||
src.filePath ||
src.relative_path ||
src.document_path ||
src.path ||
src.file ||
src.filename ||
src.name ||
(stringPayload && /[\\/]/.test(stringPayload) ? stringPayload : "");
const done = this.#toNumber(
src.done ?? src.processed ?? src.processed_files ?? src.indexed_files ?? src.completed ?? src.current ?? null
);
const total = this.#toNumber(src.total ?? src.total_files ?? src.files_total ?? src.count ?? src.max ?? null);
const failed = this.#toNumber(src.failed ?? src.failed_files ?? null);
const cacheHitFiles = this.#toNumber(src.cache_hit_files ?? src.cacheHitFiles ?? null);
const cacheMissFiles = this.#toNumber(src.cache_miss_files ?? src.cacheMissFiles ?? null);
const message = src.message || src.detail || (typeof payload === "string" ? payload : rawData);
if (!status && done == null && total == null && failed == null && cacheHitFiles == null && cacheMissFiles == null && !currentFile && !message) return null;
return { status: status || "progress", currentFile, done, total, failed, cacheHitFiles, cacheMissFiles, message, raw: payload ?? rawData };
}
#normalizeStatus(value) {
const v = String(value || "").toLowerCase();
if (!v) return "";
if (["done", "completed", "success", "finished"].includes(v)) return "done";
if (["error", "failed", "failure"].includes(v)) return "error";
return "progress";
}
#tryParseJson(value) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
#toNumber(value) {
if (value == null || value === "") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
async #pollRagJob(ragSessionId, jobId, onProgress = null, shouldExtendTimeout = null) {
let started = Date.now();
while (true) {
if (Date.now() - started >= this.timeoutMs) {
if (typeof shouldExtendTimeout === "function" && shouldExtendTimeout()) {
started = Date.now();
} else {
throw new Error("Index polling timeout");
}
}
let status;
try {
status = await this.http.request(
`/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}` `/api/rag/sessions/${encodeURIComponent(ragSessionId)}/jobs/${encodeURIComponent(jobId)}`
); );
} catch (error) {
if (typeof shouldExtendTimeout === "function" && shouldExtendTimeout()) {
await this.#sleep(this.pollMs);
continue;
}
throw error;
}
if (typeof onProgress === "function") {
const normalized = this.#normalizeProgressEvent(status, "", "");
if (normalized) {
normalized.source = "poll";
onProgress(normalized);
}
}
if (status.status === "done") return status; if (status.status === "done") return status;
if (status.status === "error") { if (status.status === "error") {
throw new Error(status.error?.desc || "Indexing failed"); throw new Error(status.error?.desc || "Indexing failed");
} }
await this.#sleep(this.pollMs); await this.#sleep(this.pollMs);
} }
throw new Error("Index polling timeout"); }
async #awaitWithTimeout(promise, timeoutMs) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs);
})
]);
} finally {
if (timer) clearTimeout(timer);
}
} }
#sleep(ms) { #sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
#isProgressComplete(state) {
if (!state) return false;
if (!Number.isFinite(state.done) || !Number.isFinite(state.total) || state.total <= 0) return false;
return state.done >= state.total;
}
#hasRecentSseActivity(lastSseActivityAt) {
if (!Number.isFinite(lastSseActivityAt) || lastSseActivityAt <= 0) return false;
return Date.now() - lastSseActivityAt <= this.sseActivityGraceMs;
}
} }

View File

@@ -11,6 +11,13 @@ export class IndexingClientMock {
void projectId; void projectId;
const jobId = `${type}-${Date.now()}`; const jobId = `${type}-${Date.now()}`;
await new Promise((resolve) => setTimeout(resolve, 550)); await new Promise((resolve) => setTimeout(resolve, 550));
return { index_job_id: jobId, status: "done", indexed_files: count, failed_files: 0 }; return {
index_job_id: jobId,
status: "done",
indexed_files: count,
failed_files: 0,
cache_hit_files: 0,
cache_miss_files: count
};
} }
} }

View File

@@ -4,6 +4,7 @@ export class ProjectLimitsPolicy {
this.hardFileLimit = 10000; this.hardFileLimit = 10000;
this.softSizeLimitBytes = 1 * 1024 * 1024; this.softSizeLimitBytes = 1 * 1024 * 1024;
this.hardSizeLimitBytes = 10 * 1024 * 1024; this.hardSizeLimitBytes = 10 * 1024 * 1024;
this.ignoredDirectoryNames = new Set(["app-data", "build", "grafana", "__pycache__"]);
} }
summarizeFileList(fileList) { summarizeFileList(fileList) {
@@ -12,7 +13,7 @@ export class ProjectLimitsPolicy {
for (const file of fileList) { for (const file of fileList) {
const relPath = (file.webkitRelativePath || file.name || "").replaceAll("\\", "/"); const relPath = (file.webkitRelativePath || file.name || "").replaceAll("\\", "/");
if (this.#isHiddenPath(relPath)) continue; if (this.#isIgnoredPath(relPath)) continue;
totalFiles += 1; totalFiles += 1;
totalBytes += Number(file.size || 0); totalBytes += Number(file.size || 0);
} }
@@ -43,11 +44,11 @@ export class ProjectLimitsPolicy {
return { softWarnings, hardErrors }; return { softWarnings, hardErrors };
} }
#isHiddenPath(path) { #isIgnoredPath(path) {
const parts = String(path || "") const parts = String(path || "")
.split("/") .split("/")
.filter(Boolean); .filter(Boolean);
return parts.some((segment) => segment.startsWith(".")); return parts.some((segment) => segment.startsWith(".") || this.ignoredDirectoryNames.has(segment));
} }
#formatBytes(bytes) { #formatBytes(bytes) {

View File

@@ -4,6 +4,7 @@ export class ProjectScanner {
constructor(textPolicy, hashService) { constructor(textPolicy, hashService) {
this.textPolicy = textPolicy; this.textPolicy = textPolicy;
this.hashService = hashService; this.hashService = hashService;
this.ignoredDirectoryNames = new Set(["app-data", "build", "grafana", "__pycache__"]);
} }
async scan(rootHandle, onProgress = null) { async scan(rootHandle, onProgress = null) {
@@ -22,7 +23,7 @@ export class ProjectScanner {
const rel = relRaw.replaceAll("\\", "/"); const rel = relRaw.replaceAll("\\", "/");
try { try {
const path = PathUtils.normalizeRelative(rel); const path = PathUtils.normalizeRelative(rel);
if (this.#isHiddenPath(path)) continue; if (this.#isIgnoredPath(path)) continue;
entries.push({ path, file }); entries.push({ path, file });
} catch { } catch {
// Skip invalid paths instead of failing the whole tree. // Skip invalid paths instead of failing the whole tree.
@@ -69,7 +70,7 @@ export class ProjectScanner {
for await (const [name, handle] of dirHandle.entries()) { for await (const [name, handle] of dirHandle.entries()) {
const relPath = currentPath ? `${currentPath}/${name}` : name; const relPath = currentPath ? `${currentPath}/${name}` : name;
const normalizedRelPath = PathUtils.normalizeRelative(relPath); const normalizedRelPath = PathUtils.normalizeRelative(relPath);
if (this.#isHiddenPath(normalizedRelPath)) continue; if (this.#isIgnoredPath(normalizedRelPath)) continue;
if (handle.kind === "directory") { if (handle.kind === "directory") {
const child = { name, path: normalizedRelPath, type: "dir", children: [] }; const child = { name, path: normalizedRelPath, type: "dir", children: [] };
@@ -152,10 +153,10 @@ export class ProjectScanner {
for (const child of node.children) this.#sortTree(child); for (const child of node.children) this.#sortTree(child);
} }
#isHiddenPath(path) { #isIgnoredPath(path) {
const parts = String(path || "") const parts = String(path || "")
.split("/") .split("/")
.filter(Boolean); .filter(Boolean);
return parts.some((segment) => segment.startsWith(".")); return parts.some((segment) => segment.startsWith(".") || this.ignoredDirectoryNames.has(segment));
} }
} }

View File

@@ -7,6 +7,8 @@ export class ProjectStore {
this.totalFileCount = 0; this.totalFileCount = 0;
this.totalBytes = 0; this.totalBytes = 0;
this.selectedFilePath = ""; this.selectedFilePath = "";
this.filePathIndex = new Map();
this.dirPathIndex = new Map();
this.listeners = new Set(); this.listeners = new Set();
} }
@@ -27,32 +29,110 @@ export class ProjectStore {
this.totalFileCount = snapshot.totalFileCount || 0; this.totalFileCount = snapshot.totalFileCount || 0;
this.totalBytes = snapshot.totalBytes || 0; this.totalBytes = snapshot.totalBytes || 0;
this.selectedFilePath = ""; this.selectedFilePath = "";
this.#rebuildPathIndexes();
this.#emit(); this.#emit();
} }
setSelectedFile(path) { setSelectedFile(path) {
this.selectedFilePath = path; const normalized = String(path || "").replaceAll("\\", "/");
const resolved = this.resolveFilePath(normalized);
this.selectedFilePath = resolved || normalized;
this.#emit(); this.#emit();
} }
upsertFile(path, content, hash) { upsertFile(path, content, hash) {
const normalized = path.replaceAll("\\", "/"); const normalized = path.replaceAll("\\", "/");
const resolved = this.resolveFilePath(normalized);
const targetPath = resolved || normalized;
const size = content.length; const size = content.length;
this.files.set(normalized, { path: normalized, content, hash, size }); this.files.set(targetPath, { path: targetPath, content, hash, size });
this.#ensureFileInTree(normalized, size); this.#setFileIndex(targetPath);
this.#ensureFileInTree(targetPath, size);
this.#emit();
}
upsertDirectory(path) {
const normalized = path.replaceAll("\\", "/");
if (!normalized) return;
this.#setDirIndex(normalized);
this.#ensureDirectoryInTree(normalized);
this.#emit(); this.#emit();
} }
removeFile(path) { removeFile(path) {
const normalized = path.replaceAll("\\", "/"); const normalized = path.replaceAll("\\", "/");
this.files.delete(normalized); const resolved = this.resolveFilePath(normalized) || normalized;
this.fileHandles.delete(normalized); this.files.delete(resolved);
this.#removeFileFromTree(normalized); this.fileHandles.delete(resolved);
this.#deleteFileIndex(resolved);
this.#removeFileFromTree(resolved);
this.#emit(); this.#emit();
} }
removeDirectory(path) {
const normalized = path.replaceAll("\\", "/");
const resolved = this.resolveDirectoryPath(normalized) || normalized;
if (!resolved) return;
this.#removeDirectoryFromTree(resolved);
const prefix = `${resolved}/`;
for (const filePath of [...this.files.keys()]) {
if (filePath === resolved || filePath.startsWith(prefix)) {
this.files.delete(filePath);
this.#deleteFileIndex(filePath);
}
}
for (const filePath of [...this.fileHandles.keys()]) {
if (filePath === resolved || filePath.startsWith(prefix)) this.fileHandles.delete(filePath);
}
for (const dirPath of [...this.dirPathIndex.values()]) {
if (dirPath === resolved || dirPath.startsWith(prefix)) this.#deleteDirIndex(dirPath);
}
this.#emit();
}
resolveFilePath(path) {
const normalized = String(path || "").replaceAll("\\", "/");
return this.filePathIndex.get(normalized.toLowerCase()) || "";
}
resolveDirectoryPath(path) {
const normalized = String(path || "").replaceAll("\\", "/");
if (!normalized) return "";
return this.dirPathIndex.get(normalized.toLowerCase()) || "";
}
hasFile(path) {
return Boolean(this.resolveFilePath(path));
}
getFile(path) {
const resolved = this.resolveFilePath(path) || String(path || "").replaceAll("\\", "/");
return this.files.get(resolved) || null;
}
getFileHandle(path) {
const resolved = this.resolveFilePath(path) || String(path || "").replaceAll("\\", "/");
return this.fileHandles.get(resolved) || null;
}
hasDirectory(path) {
const normalized = path.replaceAll("\\", "/");
if (!normalized) return true;
const resolved = this.resolveDirectoryPath(normalized);
if (resolved) return true;
const parts = normalized.split("/").filter(Boolean);
if (!parts.length) return true;
let node = this.rootNode;
for (const name of parts) {
const child = node?.children?.find((item) => item.type === "dir" && item.name.toLowerCase() === name.toLowerCase());
if (!child) return false;
node = child;
}
return true;
}
getSelectedFile() { getSelectedFile() {
return this.files.get(this.selectedFilePath) || null; return this.getFile(this.selectedFilePath);
} }
#ensureFileInTree(path, size) { #ensureFileInTree(path, size) {
@@ -80,15 +160,37 @@ export class ProjectStore {
} }
node.children = node.children || []; node.children = node.children || [];
let dir = node.children.find((child) => child.type === "dir" && child.name === name); let dir = node.children.find((child) => child.type === "dir" && child.name.toLowerCase() === name.toLowerCase());
if (!dir) { if (!dir) {
dir = { name, path: childPath, type: "dir", children: [] }; dir = { name, path: childPath, type: "dir", children: [] };
node.children.push(dir); node.children.push(dir);
this.#setDirIndex(childPath);
} }
node = dir; node = dir;
} }
} }
#ensureDirectoryInTree(path) {
if (!this.rootNode) return;
const parts = path.split("/").filter(Boolean);
if (!parts.length) return;
let node = this.rootNode;
for (let i = 0; i < parts.length; i += 1) {
const name = parts[i];
const childPath = parts.slice(0, i + 1).join("/");
node.children = node.children || [];
let dir = node.children.find((child) => child.type === "dir" && child.name.toLowerCase() === name.toLowerCase());
if (!dir) {
dir = { name, path: childPath, type: "dir", children: [] };
node.children.push(dir);
this.#setDirIndex(childPath);
}
this.#sortNode(node);
node = dir;
}
}
#sortNode(node) { #sortNode(node) {
if (!node?.children) return; if (!node?.children) return;
node.children.sort((a, b) => { node.children.sort((a, b) => {
@@ -102,6 +204,11 @@ export class ProjectStore {
this.#removeFromNode(this.rootNode, path); this.#removeFromNode(this.rootNode, path);
} }
#removeDirectoryFromTree(path) {
if (!this.rootNode?.children) return;
this.#removeDirectoryNode(this.rootNode, path);
}
#removeFromNode(node, targetPath) { #removeFromNode(node, targetPath) {
if (!node?.children) return false; if (!node?.children) return false;
const idx = node.children.findIndex((child) => child.type === "file" && child.path === targetPath); const idx = node.children.findIndex((child) => child.type === "file" && child.path === targetPath);
@@ -116,4 +223,51 @@ export class ProjectStore {
} }
return false; return false;
} }
#removeDirectoryNode(node, targetPath) {
if (!node?.children) return false;
const idx = node.children.findIndex((child) => child.type === "dir" && child.path === targetPath);
if (idx !== -1) {
node.children.splice(idx, 1);
return true;
}
for (const child of node.children) {
if (child.type !== "dir") continue;
const removed = this.#removeDirectoryNode(child, targetPath);
if (removed) return true;
}
return false;
}
#rebuildPathIndexes() {
this.filePathIndex.clear();
this.dirPathIndex.clear();
for (const path of this.files.keys()) this.#setFileIndex(path);
this.#indexDirectoryNode(this.rootNode);
}
#indexDirectoryNode(node) {
if (!node?.children) return;
for (const child of node.children) {
if (child.type !== "dir") continue;
if (child.path) this.#setDirIndex(child.path);
this.#indexDirectoryNode(child);
}
}
#setFileIndex(path) {
this.filePathIndex.set(String(path || "").toLowerCase(), path);
}
#deleteFileIndex(path) {
this.filePathIndex.delete(String(path || "").toLowerCase());
}
#setDirIndex(path) {
this.dirPathIndex.set(String(path || "").toLowerCase(), path);
}
#deleteDirIndex(path) {
this.dirPathIndex.delete(String(path || "").toLowerCase());
}
} }

View File

@@ -10,8 +10,8 @@ export class ReviewStateStore {
path: item.path, path: item.path,
status: item.status, status: item.status,
op: item.op, op: item.op,
acceptedOpIds: new Set(), acceptedBlockIds: new Set(),
stagedSelection: new Set() rejectedBlockIds: new Set()
}); });
} }
} }
@@ -30,29 +30,68 @@ export class ReviewStateStore {
const s = this.get(path); const s = this.get(path);
if (!s || s.status === "conflict") return; if (!s || s.status === "conflict") return;
s.status = status; s.status = status;
if (status === "accepted_full") {
s.rejectedBlockIds.clear();
return;
}
if (status === "rejected") {
s.acceptedBlockIds.clear();
s.rejectedBlockIds.clear();
return;
}
if (status === "pending") {
s.acceptedBlockIds.clear();
s.rejectedBlockIds.clear();
}
} }
toggleSelection(path, opId) { setAllBlocksDecision(path, blockIds, decision) {
const s = this.get(path); const s = this.get(path);
if (!s || s.status === "conflict") return; if (!s || s.status === "conflict") return;
if (s.stagedSelection.has(opId)) s.stagedSelection.delete(opId); const ids = Array.isArray(blockIds) ? blockIds : [];
else s.stagedSelection.add(opId); if (decision === "accept") {
for (const id of ids) {
s.acceptedBlockIds.add(id);
s.rejectedBlockIds.delete(id);
}
s.status = ids.length ? "accepted_full" : "pending";
return;
}
if (decision === "reject") {
for (const id of ids) {
s.acceptedBlockIds.delete(id);
s.rejectedBlockIds.add(id);
}
s.status = "rejected";
}
} }
acceptSelected(path) { setBlockDecision(path, blockId, decision) {
const s = this.get(path); const s = this.get(path);
if (!s || s.status === "conflict") return; if (!s || s.status === "conflict") return;
for (const id of s.stagedSelection) s.acceptedOpIds.add(id); if (decision === "accept") {
s.status = s.acceptedOpIds.size ? "accepted_partial" : "pending"; s.acceptedBlockIds.add(blockId);
s.stagedSelection.clear(); s.rejectedBlockIds.delete(blockId);
s.status = "accepted_partial";
return;
}
if (decision === "reject") {
s.acceptedBlockIds.delete(blockId);
s.rejectedBlockIds.add(blockId);
s.status = s.acceptedBlockIds.size > 0 ? "accepted_partial" : "pending";
}
} }
rejectSelected(path) { acceptAll() {
const s = this.get(path); for (const [path] of this.items.entries()) {
if (!s || s.status === "conflict") return; this.setFileStatus(path, "accepted_full");
for (const id of s.stagedSelection) s.acceptedOpIds.delete(id); }
s.status = s.acceptedOpIds.size ? "accepted_partial" : "pending"; }
s.stagedSelection.clear();
rejectAll() {
for (const [path] of this.items.entries()) {
this.setFileStatus(path, "rejected");
}
} }
acceptedPaths() { acceptedPaths() {
@@ -67,7 +106,6 @@ export class ReviewStateStore {
const s = this.get(path); const s = this.get(path);
if (!s) return; if (!s) return;
s.status = "applied"; s.status = "applied";
s.stagedSelection.clear();
} }
list() { list() {

View File

@@ -0,0 +1,158 @@
import { ApiHttpClient } from "./ApiHttpClient.js";
export class TaskEventsSseClient {
constructor(http = new ApiHttpClient()) {
this.http = http;
}
open(taskId, onEvent = null) {
const controller = new AbortController();
let terminalResolved = false;
let resolveTerminal;
const terminal = new Promise((resolve) => {
resolveTerminal = resolve;
});
const finishTerminal = (payload) => {
if (terminalResolved) return;
terminalResolved = true;
resolveTerminal(payload);
};
const task = this.#streamTaskEvents(taskId, onEvent, controller.signal, finishTerminal).catch((error) => {
if (controller.signal.aborted) return;
const payload = { kind: "stream_error", task_id: taskId, message: String(error?.message || error || "SSE error") };
if (typeof onEvent === "function") onEvent(payload);
});
return {
terminal,
close: async () => {
controller.abort();
finishTerminal({ kind: "aborted", task_id: taskId });
await task;
}
};
}
async #streamTaskEvents(taskId, onEvent, signal, onTerminal) {
const response = await fetch(this.http.resolveUrl(`/api/events?task_id=${encodeURIComponent(taskId)}`), {
method: "GET",
headers: { Accept: "text/event-stream" },
signal
});
if (!response.ok) throw new Error(`SSE HTTP ${response.status}`);
if (!response.body) throw new Error("SSE stream is empty");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const state = { eventName: "", dataLines: [] };
const dispatch = () => {
if (!state.dataLines.length) return;
const rawData = state.dataLines.join("\n");
state.dataLines = [];
const payload = this.#tryParseJson(rawData);
const normalized = this.#normalizeEvent(payload, rawData, state.eventName, taskId);
state.eventName = "";
if (!normalized) return;
if (typeof onEvent === "function") onEvent(normalized);
if (normalized.kind === "result" || normalized.kind === "error") onTerminal(normalized);
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
if (!line) {
dispatch();
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith(":")) {
newlineIndex = buffer.indexOf("\n");
continue;
}
if (line.startsWith("event:")) {
state.eventName = line.slice(6).trim();
} else if (line.startsWith("data:")) {
state.dataLines.push(line.slice(5).trimStart());
}
newlineIndex = buffer.indexOf("\n");
}
}
if (state.dataLines.length) dispatch();
}
#normalizeEvent(payload, rawData, eventName, fallbackTaskId) {
const src = payload && typeof payload === "object" ? payload : {};
const name = String(src.event || src.type || eventName || "").trim();
const taskId = src.task_id || fallbackTaskId;
if (name === "task_progress") {
return {
kind: "progress",
task_id: taskId,
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {},
progress: src.progress
};
}
if (name === "task_thinking") {
return {
kind: "thinking",
task_id: taskId,
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {},
heartbeat: Boolean(src.meta?.heartbeat)
};
}
if (name === "task_result") {
return {
kind: "result",
task_id: taskId,
status: src.status || "done",
result_type: src.result_type || "",
answer: src.answer || "",
changeset: Array.isArray(src.changeset) ? src.changeset : [],
meta: src.meta || {}
};
}
if (name === "task_error") {
const message = src.error?.desc || src.error?.message || src.error || src.message || rawData;
return { kind: "error", task_id: taskId, message: String(message || "Task failed"), error: src.error || null, meta: src.meta || {} };
}
if (name === "task_status") {
return {
kind: "status",
task_id: taskId,
status: src.status || "",
stage: src.stage || "",
message: src.message || "",
meta: src.meta || {}
};
}
if (src.status === "error") {
const message = src.error?.desc || src.error?.message || src.error || src.message || rawData;
return { kind: "error", task_id: taskId, message: String(message || "Task failed"), error: src.error || null, meta: src.meta || {} };
}
return null;
}
#tryParseJson(value) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,10 @@ export class AppView {
this.expandedTreeDirs = new Set(); this.expandedTreeDirs = new Set();
this.lastTreeRootKey = ""; this.lastTreeRootKey = "";
this.markdownRenderVersion = 0; this.markdownRenderVersion = 0;
this.treeInlineEdit = null;
this.treeInlineCallbacks = null;
this.reviewCollapsedBlocks = new Set();
this.taskProgressWindows = new Map();
this.el = { this.el = {
layout: document.getElementById("layout-root"), layout: document.getElementById("layout-root"),
splitterLeft: document.getElementById("splitter-left"), splitterLeft: document.getElementById("splitter-left"),
@@ -23,11 +27,21 @@ export class AppView {
indexingModal: document.getElementById("indexing-modal"), indexingModal: document.getElementById("indexing-modal"),
indexingFile: document.getElementById("indexing-file"), indexingFile: document.getElementById("indexing-file"),
indexingRemaining: document.getElementById("indexing-remaining"), indexingRemaining: document.getElementById("indexing-remaining"),
indexingTotalIndexed: document.getElementById("indexing-total-indexed"),
indexingCacheHit: document.getElementById("indexing-cache-hit"),
indexingCacheMiss: document.getElementById("indexing-cache-miss"),
indexingReuseRatio: document.getElementById("indexing-reuse-ratio"),
indexingProgressBar: document.getElementById("indexing-progress-bar"), indexingProgressBar: document.getElementById("indexing-progress-bar"),
indexingCloseBtn: document.getElementById("indexing-close-btn"),
treeRoot: document.getElementById("tree-root"), treeRoot: document.getElementById("tree-root"),
treeContextMenu: document.getElementById("tree-context-menu"),
treeMenuCreateDir: document.getElementById("tree-menu-create-dir"),
treeMenuRename: document.getElementById("tree-menu-rename"),
treeMenuCreate: document.getElementById("tree-menu-create"),
treeMenuDelete: document.getElementById("tree-menu-delete"),
fileTabs: document.getElementById("file-tabs"), fileTabs: document.getElementById("file-tabs"),
newTextTabBtn: document.getElementById("new-text-tab"),
mdToggleBtn: document.getElementById("md-toggle-mode"), mdToggleBtn: document.getElementById("md-toggle-mode"),
editorEmptyState: document.getElementById("editor-empty-state"),
fileEditor: document.getElementById("file-editor"), fileEditor: document.getElementById("file-editor"),
fileEditorMonaco: document.getElementById("file-editor-monaco"), fileEditorMonaco: document.getElementById("file-editor-monaco"),
mdPreview: document.getElementById("md-preview"), mdPreview: document.getElementById("md-preview"),
@@ -39,7 +53,6 @@ export class AppView {
changeList: document.getElementById("change-list"), changeList: document.getElementById("change-list"),
reviewWrap: document.querySelector(".review-wrap"), reviewWrap: document.querySelector(".review-wrap"),
toolbar: document.getElementById("review-toolbar"), toolbar: document.getElementById("review-toolbar"),
applyAccepted: document.getElementById("apply-accepted"),
newChatSessionBtn: document.getElementById("new-chat-session"), newChatSessionBtn: document.getElementById("new-chat-session"),
chatLog: document.getElementById("chat-log"), chatLog: document.getElementById("chat-log"),
chatForm: document.getElementById("chat-form"), chatForm: document.getElementById("chat-form"),
@@ -47,9 +60,14 @@ export class AppView {
}; };
this.reviewAvailable = false; this.reviewAvailable = false;
this.centerMode = "file"; this.centerMode = "file";
this.noFileState = true;
this.treeContextSelection = null;
this.treeContextCallbacks = null;
this.setRagStatus("red"); this.setRagStatus("red");
this.setMarkdownToggleVisible(false); this.setMarkdownToggleVisible(false);
this.setNoFileState(true);
this.setReviewVisible(false); this.setReviewVisible(false);
this.#initTreeContextMenu();
void this.#initMonacoEditor(); void this.#initMonacoEditor();
} }
@@ -68,14 +86,33 @@ export class AppView {
showIndexingModal() { showIndexingModal() {
this.el.indexingModal.classList.remove("hidden"); this.el.indexingModal.classList.remove("hidden");
this.updateIndexingModal({ currentFile: "—", remaining: null, done: 0, total: 0 }); this.setIndexingModalCloseEnabled(false);
this.updateIndexingModal({
currentFile: "—",
remaining: null,
done: 0,
total: 0,
indexedFiles: 0,
cacheHitFiles: 0,
cacheMissFiles: 0
});
} }
hideIndexingModal() { hideIndexingModal() {
this.el.indexingModal.classList.add("hidden"); this.el.indexingModal.classList.add("hidden");
} }
updateIndexingModal({ currentFile, remaining, done, total, phase }) { bindIndexingModalClose(onClose) {
if (!this.el.indexingCloseBtn) return;
this.el.indexingCloseBtn.onclick = () => onClose?.();
}
setIndexingModalCloseEnabled(enabled) {
if (!this.el.indexingCloseBtn) return;
this.el.indexingCloseBtn.disabled = !enabled;
}
updateIndexingModal({ currentFile, remaining, done, total, phase, indexedFiles, cacheHitFiles, cacheMissFiles }) {
if (typeof phase === "string" && phase.length) { if (typeof phase === "string" && phase.length) {
const title = this.el.indexingModal?.querySelector("h3"); const title = this.el.indexingModal?.querySelector("h3");
if (title) title.textContent = phase; if (title) title.textContent = phase;
@@ -91,12 +128,26 @@ export class AppView {
this.el.indexingRemaining.textContent = "—"; this.el.indexingRemaining.textContent = "—";
} }
const normalizedIndexed = Number.isFinite(indexedFiles) ? indexedFiles : Number.isFinite(done) ? done : 0;
const normalizedCacheHit = Number.isFinite(cacheHitFiles) ? cacheHitFiles : 0;
const normalizedCacheMiss = Number.isFinite(cacheMissFiles) ? cacheMissFiles : 0;
if (this.el.indexingTotalIndexed) this.el.indexingTotalIndexed.textContent = `${normalizedIndexed}`;
if (this.el.indexingCacheHit) this.el.indexingCacheHit.textContent = `${normalizedCacheHit}`;
if (this.el.indexingCacheMiss) this.el.indexingCacheMiss.textContent = `${normalizedCacheMiss}`;
if (this.el.indexingReuseRatio) {
const ratio = (normalizedCacheHit / Math.max(1, normalizedIndexed)) * 100;
this.el.indexingReuseRatio.textContent = `${Math.round(ratio)}%`;
}
const progressWrap = this.el.indexingProgressBar?.parentElement;
if (Number.isFinite(done) && Number.isFinite(total) && total > 0) { if (Number.isFinite(done) && Number.isFinite(total) && total > 0) {
progressWrap?.classList.remove("indeterminate");
const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100))); const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100)));
this.el.indexingProgressBar.style.width = `${percent}%`; this.el.indexingProgressBar.style.width = `${percent}%`;
return; return;
} }
this.el.indexingProgressBar.style.width = "0%"; progressWrap?.classList.add("indeterminate");
this.el.indexingProgressBar.style.width = "35%";
} }
setRagStatus(status, details = {}) { setRagStatus(status, details = {}) {
@@ -109,12 +160,18 @@ export class AppView {
const updatedAt = details.updatedAt ? this.#formatTime(details.updatedAt) : ""; const updatedAt = details.updatedAt ? this.#formatTime(details.updatedAt) : "";
const indexed = Number.isFinite(details.indexedFiles) ? details.indexedFiles : null; const indexed = Number.isFinite(details.indexedFiles) ? details.indexedFiles : null;
const failed = Number.isFinite(details.failedFiles) ? details.failedFiles : null; const failed = Number.isFinite(details.failedFiles) ? details.failedFiles : null;
const cacheHit = Number.isFinite(details.cacheHitFiles) ? details.cacheHitFiles : 0;
const cacheMiss = Number.isFinite(details.cacheMissFiles) ? details.cacheMissFiles : 0;
const reusePercent = Math.round((cacheHit / Math.max(1, indexed ?? 0)) * 100);
const extra = details.message ? `\n${details.message}` : ""; const extra = details.message ? `\n${details.message}` : "";
if (status === "green") { if (status === "green") {
dot.classList.add("rag-green"); dot.classList.add("rag-green");
text.textContent = "RAG: готов"; text.textContent = "RAG: готов";
container.title = `RAG: готов\nИндексировано: ${indexed ?? 0}\nОшибок: ${failed ?? 0}${updatedAt ? `\nОбновлено: ${updatedAt}` : ""}${extra}`; container.title =
`RAG: готов\nИндексировано: ${indexed ?? 0}\nОшибок: ${failed ?? 0}\n` +
`С кэшем rag_repo: ${cacheHit}\nБез кэша rag_repo: ${cacheMiss}\nДоля reuse: ${reusePercent}%` +
`${updatedAt ? `\nОбновлено: ${updatedAt}` : ""}${extra}`;
return; return;
} }
if (status === "yellow") { if (status === "yellow") {
@@ -138,7 +195,7 @@ export class AppView {
} }
setApplyEnabled(enabled) { setApplyEnabled(enabled) {
this.el.applyAccepted.disabled = !enabled; if (this.el.toolbar) this.el.toolbar.classList.toggle("hidden", !enabled);
} }
setReviewVisible(visible) { setReviewVisible(visible) {
@@ -151,6 +208,7 @@ export class AppView {
const isReview = this.centerMode === "review"; const isReview = this.centerMode === "review";
this.el.editorFooter.classList.toggle("hidden", isReview); this.el.editorFooter.classList.toggle("hidden", isReview);
if (isReview) { if (isReview) {
this.el.editorEmptyState.classList.add("hidden");
this.el.reviewWrap.classList.remove("hidden"); this.el.reviewWrap.classList.remove("hidden");
this.el.mdPreview.classList.add("hidden"); this.el.mdPreview.classList.add("hidden");
this.#setEditorVisible(false); this.#setEditorVisible(false);
@@ -159,6 +217,15 @@ export class AppView {
this.el.reviewWrap.classList.add("hidden"); this.el.reviewWrap.classList.add("hidden");
} }
setNoFileState(isNoFile) {
this.noFileState = Boolean(isNoFile);
this.el.editorEmptyState.classList.toggle("hidden", !this.noFileState);
if (this.noFileState) {
this.el.mdPreview.classList.add("hidden");
this.#setEditorVisible(false);
}
}
setEditorEnabled(enabled) { setEditorEnabled(enabled) {
this.el.fileEditor.readOnly = !enabled; this.el.fileEditor.readOnly = !enabled;
if (this.monacoAdapter) this.monacoAdapter.setReadOnly(!enabled); if (this.monacoAdapter) this.monacoAdapter.setReadOnly(!enabled);
@@ -184,10 +251,12 @@ export class AppView {
} }
bindNewTextTab(onCreate) { bindNewTextTab(onCreate) {
if (!this.el.newTextTabBtn) return;
this.el.newTextTabBtn.onclick = onCreate; this.el.newTextTabBtn.onclick = onCreate;
} }
setNewTextTabEnabled(enabled) { setNewTextTabEnabled(enabled) {
if (!this.el.newTextTabBtn) return;
this.el.newTextTabBtn.disabled = !enabled; this.el.newTextTabBtn.disabled = !enabled;
} }
@@ -195,8 +264,18 @@ export class AppView {
this.el.newChatSessionBtn.onclick = onNewSession; this.el.newChatSessionBtn.onclick = onNewSession;
} }
bindTreeContextActions(onRename, onCreateFile, onCreateDir, onDelete) {
this.treeContextCallbacks = { onRename, onCreateFile, onCreateDir, onDelete };
}
setTreeInlineEditState(state, callbacks) {
this.treeInlineEdit = state || null;
this.treeInlineCallbacks = callbacks || null;
}
clearChat() { clearChat() {
this.el.chatLog.innerHTML = ""; this.el.chatLog.innerHTML = "";
this.taskProgressWindows.clear();
} }
setEditorActionsState({ hasFile, isDirty, infoText }) { setEditorActionsState({ hasFile, isDirty, infoText }) {
@@ -213,7 +292,7 @@ export class AppView {
this.el.mdToggleBtn.textContent = "✏️"; this.el.mdToggleBtn.textContent = "✏️";
this.el.mdToggleBtn.title = "Текущий режим: редактирование"; this.el.mdToggleBtn.title = "Текущий режим: редактирование";
this.el.mdPreview.classList.add("hidden"); this.el.mdPreview.classList.add("hidden");
this.#setEditorVisible(true); if (!this.noFileState) this.#setEditorVisible(true);
} }
setMarkdownMode(mode) { setMarkdownMode(mode) {
@@ -236,15 +315,80 @@ export class AppView {
} }
appendChat(role, text) { appendChat(role, text) {
if (!["user", "assistant"].includes(role)) return; if (!["user", "assistant", "system", "error"].includes(role)) return;
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "chat-entry"; div.className = `chat-entry chat-entry-${role}`;
div.textContent = `[${role}] ${text}`; div.textContent = text || "";
this.el.chatLog.appendChild(div); this.el.chatLog.appendChild(div);
this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight; this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight;
} }
renderTree(rootNode, selectedPath, onSelect) { appendIndexDoneSummary({ indexedFiles = 0, cacheHitFiles = 0, cacheMissFiles = 0 }) {
const normalizedIndexed = Number.isFinite(indexedFiles) ? indexedFiles : 0;
const normalizedCacheHit = Number.isFinite(cacheHitFiles) ? cacheHitFiles : 0;
const normalizedCacheMiss = Number.isFinite(cacheMissFiles) ? cacheMissFiles : 0;
const reusePercent = Math.round((normalizedCacheHit / Math.max(1, normalizedIndexed)) * 100);
const entry = document.createElement("div");
entry.className = "chat-entry chat-entry-system";
const status = document.createElement("div");
status.textContent = "Индексация: DONE";
const summary = document.createElement("div");
summary.textContent =
`Всего проиндексировано: ${normalizedIndexed}` +
`С кэшем rag_repo: ${normalizedCacheHit}` +
`Без кэша rag_repo: ${normalizedCacheMiss}` +
`Доля reuse: ${reusePercent}%`;
entry.append(status, summary);
this.el.chatLog.appendChild(entry);
this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight;
}
upsertTaskProgress(taskId, text, progressPercent = null) {
const key = String(taskId || "active");
let entry = this.taskProgressWindows.get(key);
if (!entry) {
const root = document.createElement("div");
root.className = "chat-entry chat-entry-assistant chat-entry-intermediate chat-sse-window";
const list = document.createElement("div");
list.className = "chat-sse-list";
root.appendChild(list);
this.el.chatLog.appendChild(root);
entry = { root, list };
this.taskProgressWindows.set(key, entry);
}
const message = String(text || "Агент обрабатывает запрос...");
const normalized = Number.isFinite(progressPercent) ? Math.max(0, Math.min(100, Math.round(progressPercent))) : null;
const row = document.createElement("div");
row.className = "chat-sse-line";
const textEl = document.createElement("div");
textEl.className = "chat-sse-text";
textEl.textContent = normalized == null ? message : `${message} (${normalized}%)`;
row.appendChild(textEl);
if (normalized != null) {
const progressWrap = document.createElement("div");
progressWrap.className = "chat-task-progress";
const progressBar = document.createElement("div");
progressBar.className = "chat-task-progress-bar";
progressBar.style.width = `${normalized}%`;
progressWrap.appendChild(progressBar);
row.appendChild(progressWrap);
}
entry.list.appendChild(row);
entry.list.scrollTop = entry.list.scrollHeight;
this.el.chatLog.scrollTop = this.el.chatLog.scrollHeight;
}
completeTaskProgress(taskId) {
const key = String(taskId || "active");
const entry = this.taskProgressWindows.get(key);
if (!entry) return;
entry.root.classList.add("chat-sse-window-complete");
}
renderTree(rootNode, selectedPath, treeSelection, onSelectFile, onSelectNode) {
this.#hideTreeContextMenu();
this.el.treeRoot.innerHTML = ""; this.el.treeRoot.innerHTML = "";
if (!rootNode) { if (!rootNode) {
this.el.treeRoot.textContent = "Директория не выбрана"; this.el.treeRoot.textContent = "Директория не выбрана";
@@ -265,12 +409,18 @@ export class AppView {
this.lastTreeRootKey = rootKey; this.lastTreeRootKey = rootKey;
} }
this.#expandSelectedPathParents(selectedPath); this.#expandSelectedPathParents(selectedPath);
if (this.treeInlineEdit?.parentPath) this.expandedTreeDirs.add(this.treeInlineEdit.parentPath);
const renderNode = (node, depth, isRoot = false) => { const renderNode = (node, depth, isRoot = false) => {
const line = document.createElement("div"); const line = document.createElement("div");
line.className = "tree-item"; line.className = "tree-item";
line.style.paddingLeft = `${depth * 14}px`; line.style.paddingLeft = `${depth * 14}px`;
const isRenameTarget = this.treeInlineEdit?.mode === "rename" && this.treeInlineEdit.targetPath === (node.path || "");
if (node.type === "dir") { if (node.type === "dir") {
if (isRenameTarget) {
this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth, this.treeInlineEdit));
return;
}
const hasChildren = Array.isArray(node.children) && node.children.length > 0; const hasChildren = Array.isArray(node.children) && node.children.length > 0;
const isExpanded = isRoot || this.expandedTreeDirs.has(node.path); const isExpanded = isRoot || this.expandedTreeDirs.has(node.path);
const arrow = hasChildren ? (isExpanded ? "▾" : "▸") : "•"; const arrow = hasChildren ? (isExpanded ? "▾" : "▸") : "•";
@@ -281,24 +431,55 @@ export class AppView {
line.onclick = () => { line.onclick = () => {
if (this.expandedTreeDirs.has(node.path)) this.expandedTreeDirs.delete(node.path); if (this.expandedTreeDirs.has(node.path)) this.expandedTreeDirs.delete(node.path);
else this.expandedTreeDirs.add(node.path); else this.expandedTreeDirs.add(node.path);
this.renderTree(rootNode, selectedPath, onSelect); onSelectNode?.({ type: "dir", path: node.path || "" });
}; };
} else {
line.onclick = () => onSelectNode?.({ type: "dir", path: node.path || "" });
} }
line.oncontextmenu = (event) => {
event.preventDefault();
const selection = { type: "dir", path: node.path || "" };
onSelectNode?.(selection);
this.#showTreeContextMenu(selection, event.clientX, event.clientY);
};
if (treeSelection?.type === "dir" && treeSelection.path === (node.path || "")) line.classList.add("tree-item-selected");
this.el.treeRoot.appendChild(line); this.el.treeRoot.appendChild(line);
if (isExpanded) { if (isExpanded) {
if (this.treeInlineEdit?.mode === "create" && (node.path || "") === (this.treeInlineEdit.parentPath || "")) {
this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth + 1, this.treeInlineEdit));
}
for (const child of node.children) renderNode(child, depth + 1, false); for (const child of node.children) renderNode(child, depth + 1, false);
} }
return; return;
} }
const marker = "📄"; const marker = "📄";
if (isRenameTarget) {
this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(depth, this.treeInlineEdit));
return;
}
const suffix = node.type === "file" && node.supported === false ? " (skip)" : ""; const suffix = node.type === "file" && node.supported === false ? " (skip)" : "";
line.textContent = `${marker} ${node.name}${suffix}`; line.textContent = `${marker} ${node.name}${suffix}`;
if (node.path === selectedPath) line.style.fontWeight = "700"; if (node.path === selectedPath) line.style.fontWeight = "700";
if (node.type === "file" && node.supported !== false) line.onclick = () => onSelect(node.path); if (treeSelection?.type === "file" && treeSelection.path === node.path) line.classList.add("tree-item-selected");
if (node.type === "file" && node.supported !== false) {
line.onclick = () => {
onSelectNode?.({ type: "file", path: node.path });
onSelectFile(node.path);
};
line.oncontextmenu = (event) => {
event.preventDefault();
const selection = { type: "file", path: node.path };
onSelectNode?.(selection);
this.#showTreeContextMenu(selection, event.clientX, event.clientY);
};
}
this.el.treeRoot.appendChild(line); this.el.treeRoot.appendChild(line);
}; };
if (this.treeInlineEdit?.mode === "create" && !this.treeInlineEdit.parentPath) {
this.el.treeRoot.appendChild(this.#buildTreeInlineEditor(0, this.treeInlineEdit));
}
renderNode(rootNode, 0, true); renderNode(rootNode, 0, true);
} }
@@ -341,6 +522,17 @@ export class AppView {
openBtn.onclick = () => reviewTab.onClick?.(); openBtn.onclick = () => reviewTab.onClick?.();
tab.append(openBtn); tab.append(openBtn);
if (reviewTab.closable) {
const closeBtn = document.createElement("button");
closeBtn.className = "tab-close";
closeBtn.type = "button";
closeBtn.textContent = "x";
closeBtn.onclick = (event) => {
event.stopPropagation();
reviewTab.onClose?.();
};
tab.append(closeBtn);
}
this.el.fileTabs.appendChild(tab); this.el.fileTabs.appendChild(tab);
} }
} }
@@ -361,51 +553,124 @@ export class AppView {
if (this.monacoAdapter) this.monacoAdapter.setLanguageByPath(path || ""); if (this.monacoAdapter) this.monacoAdapter.setLanguageByPath(path || "");
} }
renderChanges(changes, activePath, onPick) { renderReviewByFiles(changes, onSetBlockDecision, onFileDecision) {
this.el.changeList.innerHTML = ""; this.el.changeList.innerHTML = "";
if (!changes.length) {
this.el.toolbar.classList.add("hidden");
this.el.diffView.innerHTML = ""; this.el.diffView.innerHTML = "";
return; if (!changes.length) return;
}
this.el.toolbar.classList.remove("hidden");
for (const change of changes) { for (const change of changes) {
const review = this.reviewStore.get(change.path); const review = this.reviewStore.get(change.path);
const btn = document.createElement("button"); const section = document.createElement("section");
btn.className = `change-btn ${change.path === activePath ? "active" : ""}`; section.className = "review-file";
btn.textContent = `${change.op} ${change.path} [${review?.status || "pending"}]`;
btn.onclick = () => onPick(change.path); const blocks = Array.isArray(change.blocks) ? change.blocks : [];
this.el.changeList.appendChild(btn); const unresolvedBlocks = blocks.filter((block) => {
} const accepted = review?.status === "accepted_full" || review?.acceptedBlockIds?.has(block.id);
const rejected = review?.status === "rejected" || review?.rejectedBlockIds?.has(block.id);
return !(accepted || rejected);
});
const fileActionsDisabled = unresolvedBlocks.length === 0;
const header = document.createElement("div");
header.className = "review-file-header";
const title = document.createElement("div");
title.className = "review-file-title";
title.textContent = `${change.path} [${review?.status || "pending"}]`;
const fileActions = document.createElement("div");
fileActions.className = "review-file-actions";
const acceptFileBtn = document.createElement("button");
acceptFileBtn.textContent = "Принять файл";
acceptFileBtn.disabled = fileActionsDisabled;
acceptFileBtn.onclick = () => onFileDecision(change.path, "accept");
const rejectFileBtn = document.createElement("button");
rejectFileBtn.textContent = "Отклонить файл";
rejectFileBtn.disabled = fileActionsDisabled;
rejectFileBtn.onclick = () => onFileDecision(change.path, "reject");
fileActions.append(acceptFileBtn, rejectFileBtn);
header.append(title, fileActions);
section.appendChild(header);
for (const block of blocks) {
const accepted = review?.status === "accepted_full" || review?.acceptedBlockIds?.has(block.id);
const rejected = review?.status === "rejected" || review?.rejectedBlockIds?.has(block.id);
const decided = accepted || rejected;
const blockEl = document.createElement("div");
blockEl.className = `diff-block ${accepted ? "accepted" : rejected ? "rejected" : "pending"}`;
const blockKey = `${change.path}::${block.id}`;
const shouldCollapseByDefault = decided;
const isCollapsed = this.reviewCollapsedBlocks.has(blockKey) || shouldCollapseByDefault;
if (isCollapsed) this.reviewCollapsedBlocks.add(blockKey);
else this.reviewCollapsedBlocks.delete(blockKey);
blockEl.classList.toggle("collapsed", isCollapsed);
const actions = document.createElement("div");
actions.className = "diff-block-actions";
const blockTitle = document.createElement("div");
blockTitle.className = "diff-block-title";
blockTitle.textContent = `Блок ${block.id}${accepted ? "принят" : rejected ? "отклонен" : "ожидает ревью"}`;
const controls = document.createElement("div");
controls.className = "diff-block-controls";
const acceptBtn = document.createElement("button");
acceptBtn.textContent = "Принять";
acceptBtn.className = accepted ? "active" : "";
acceptBtn.onclick = (event) => {
event.stopPropagation();
onSetBlockDecision(change.path, block.id, "accept");
};
const rejectBtn = document.createElement("button");
rejectBtn.textContent = "Отклонить";
rejectBtn.className = rejected ? "active" : "";
rejectBtn.onclick = (event) => {
event.stopPropagation();
onSetBlockDecision(change.path, block.id, "reject");
};
controls.append(acceptBtn, rejectBtn);
actions.append(blockTitle, controls);
const toggleCollapsed = () => {
if (this.reviewCollapsedBlocks.has(blockKey)) this.reviewCollapsedBlocks.delete(blockKey);
else this.reviewCollapsedBlocks.add(blockKey);
blockEl.classList.toggle("collapsed", this.reviewCollapsedBlocks.has(blockKey));
};
actions.onclick = () => toggleCollapsed();
blockEl.onclick = () => toggleCollapsed();
blockEl.appendChild(actions);
const body = document.createElement("div");
body.className = "diff-block-body";
if (!decided) {
for (const line of block.contextBefore || []) {
const row = document.createElement("div");
row.className = "diff-line context";
const text = document.createElement("span");
text.textContent = ` ${line}`;
row.append(text);
body.appendChild(row);
} }
renderDiff(change, onToggleLine) { for (const op of block.ops) {
this.el.diffView.innerHTML = "";
if (!change) return;
const review = this.reviewStore.get(change.path);
for (const op of change.diffOps) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = `diff-line ${op.kind}`; row.className = `diff-line ${op.kind}`;
const marker = document.createElement("span"); const text = document.createElement("span");
text.textContent = op.kind === "add" ? `+ ${op.newLine}` : `- ${op.oldLine}`;
if (op.kind === "equal") marker.textContent = " "; row.append(text);
else { body.appendChild(row);
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = review?.stagedSelection?.has(op.id) || false;
cb.onchange = () => onToggleLine(change.path, op.id);
marker.appendChild(cb);
} }
for (const line of block.contextAfter || []) {
const row = document.createElement("div");
row.className = "diff-line context";
const text = document.createElement("span"); const text = document.createElement("span");
if (op.kind === "add") text.textContent = `+ ${op.newLine}`; text.textContent = ` ${line}`;
else if (op.kind === "remove") text.textContent = `- ${op.oldLine}`; row.append(text);
else text.textContent = ` ${op.oldLine}`; body.appendChild(row);
}
row.append(marker, text); }
this.el.diffView.appendChild(row); blockEl.appendChild(body);
section.appendChild(blockEl);
}
this.el.diffView.appendChild(section);
} }
} }
@@ -452,4 +717,88 @@ export class AppView {
if (renderVersion !== this.markdownRenderVersion) return; if (renderVersion !== this.markdownRenderVersion) return;
await this.mermaidRenderer.render(this.el.mdPreview); await this.mermaidRenderer.render(this.el.mdPreview);
} }
#initTreeContextMenu() {
if (!this.el.treeContextMenu) return;
this.el.treeMenuCreateDir.onclick = () => {
this.#hideTreeContextMenu();
this.treeContextCallbacks?.onCreateDir?.();
};
this.el.treeMenuCreate.onclick = () => {
this.#hideTreeContextMenu();
this.treeContextCallbacks?.onCreateFile?.();
};
this.el.treeMenuDelete.onclick = () => {
this.#hideTreeContextMenu();
this.treeContextCallbacks?.onDelete?.();
};
this.el.treeMenuRename.onclick = () => {
this.#hideTreeContextMenu();
this.treeContextCallbacks?.onRename?.();
};
document.addEventListener("click", () => this.#hideTreeContextMenu());
window.addEventListener("blur", () => this.#hideTreeContextMenu());
window.addEventListener("resize", () => this.#hideTreeContextMenu());
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") this.#hideTreeContextMenu();
});
}
#showTreeContextMenu(selection, x, y) {
const menu = this.el.treeContextMenu;
if (!menu) return;
this.treeContextSelection = selection;
const isFile = selection?.type === "file";
const isDir = selection?.type === "dir";
this.el.treeMenuRename.disabled = !(isFile || isDir);
this.el.treeMenuDelete.disabled = !(isFile || isDir);
this.el.treeMenuCreate.disabled = !(isFile || isDir);
this.el.treeMenuCreateDir.disabled = !(isFile || isDir);
menu.classList.remove("hidden");
const margin = 8;
const rect = menu.getBoundingClientRect();
const maxLeft = Math.max(window.innerWidth - rect.width - margin, margin);
const maxTop = Math.max(window.innerHeight - rect.height - margin, margin);
const left = Math.min(Math.max(x, margin), maxLeft);
const top = Math.min(Math.max(y, margin), maxTop);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
#hideTreeContextMenu() {
const menu = this.el.treeContextMenu;
if (!menu) return;
menu.classList.add("hidden");
}
#buildTreeInlineEditor(depth, state) {
const line = document.createElement("div");
line.className = "tree-item";
line.style.paddingLeft = `${depth * 14}px`;
const input = document.createElement("input");
input.type = "text";
input.className = "tree-inline-input";
input.value = state.defaultName || "";
input.onclick = (event) => event.stopPropagation();
input.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.treeInlineCallbacks?.onSubmit?.(input.value);
} else if (event.key === "Escape") {
event.preventDefault();
this.treeInlineCallbacks?.onCancel?.();
}
};
input.onblur = () => this.treeInlineCallbacks?.onCancel?.();
line.appendChild(input);
window.setTimeout(() => {
input.focus();
input.select();
}, 0);
return line;
}
} }

View File

@@ -12,7 +12,7 @@
--left: 15%; --left: 15%;
--center: 65%; --center: 65%;
--right: 20%; --right: 20%;
--splitter: 8px; --splitter: 1px;
--outer-gap: 10px; --outer-gap: 10px;
--title-row-h: 34px; --title-row-h: 34px;
--control-row-h: 42px; --control-row-h: 42px;
@@ -53,7 +53,7 @@ body {
} }
.panel { .panel {
padding: 10px; padding: 10px 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
@@ -186,15 +186,17 @@ textarea {
} }
.splitter { .splitter {
background: linear-gradient(180deg, #1b3c69 0%, #274f86 40%, #1b3c69 100%); background: var(--line);
cursor: col-resize; cursor: col-resize;
} }
.splitter:hover { .splitter:hover {
filter: brightness(1.2); background: #3a6597;
} }
.tabs-row { .tabs-row {
border-bottom: 1px solid var(--line);
padding-bottom: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -313,6 +315,20 @@ textarea {
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden;
}
.editor-empty-state {
flex: 1;
min-height: 0;
border: 1px dashed var(--line);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 14px;
background: #0b1830;
} }
.md-toggle { .md-toggle {
@@ -512,8 +528,15 @@ textarea {
} }
.review-wrap { .review-wrap {
flex: 1; flex: 1 1 auto;
min-height: 0;
height: 100%;
margin-top: 0; margin-top: 0;
overflow: auto;
overflow-x: hidden;
padding-right: 2px;
padding-bottom: 12px;
scrollbar-gutter: stable both-edges;
} }
.editor-review { .editor-review {
@@ -532,23 +555,118 @@ textarea {
.chat-log { .chat-log {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; min-width: 0;
overflow-y: auto;
overflow-x: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-start;
scrollbar-gutter: stable both-edges;
font-size: 13px;
} }
.chat-entry { .chat-entry {
margin-bottom: 8px; margin-bottom: 8px;
padding-bottom: 8px; padding: 6px 8px;
border-bottom: 1px dotted var(--line); border: 1px solid rgba(121, 149, 196, 0.25);
border-radius: 8px;
max-width: 88%;
min-width: 0;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
word-wrap: break-word;
align-self: flex-start;
}
.chat-entry-user {
color: var(--text);
align-self: flex-end;
text-align: right;
background: #14335f;
}
.chat-entry-system {
color: #9ab0d7;
background: rgba(33, 56, 94, 0.35);
}
.chat-entry-error {
color: #ff8d8d;
background: rgba(80, 30, 37, 0.35);
}
.chat-entry-assistant {
color: #f6f8ff;
background: rgba(26, 44, 77, 0.5);
max-width: 85%;
}
.chat-entry-intermediate {
color: #b8bfd0;
}
.chat-task-progress {
margin-top: 8px;
width: 100%;
height: 4px;
border-radius: 4px;
overflow: hidden;
background: rgba(191, 202, 226, 0.25);
}
.chat-task-progress-bar {
height: 100%;
width: 0%;
border-radius: 4px;
background: #e6ecfa;
transition: width 180ms ease;
} }
.chat-entry:last-child { .chat-entry:last-child {
margin-bottom: 0; margin-bottom: 0;
padding-bottom: 0; }
border-bottom: none;
.chat-sse-window {
padding: 6px 8px;
background: rgba(20, 31, 52, 0.65);
border: none;
max-width: 85%;
}
.chat-sse-window-complete {
border-color: rgba(141, 157, 186, 0.25);
}
.chat-sse-list {
height: 110px;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 4px;
overflow-y: scroll;
overflow-x: hidden;
scrollbar-gutter: stable;
font-size: 12px;
}
.chat-sse-line {
color: #97a4bd;
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.chat-sse-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: normal;
line-height: 1.35;
}
.chat-sse-line .chat-task-progress {
margin-top: 2px;
} }
.chat-form { .chat-form {
@@ -595,18 +713,115 @@ textarea {
} }
.diff-view { .diff-view {
overflow: auto;
min-height: 120px; min-height: 120px;
flex: 1; overflow: visible;
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.review-file {
display: flex;
flex-direction: column;
gap: 8px;
}
.review-file-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.review-file-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-size: 14px;
font-weight: 600;
}
.review-file-actions {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.review-file-actions > button {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.diff-block {
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
}
.diff-block.accepted {
border-color: #2f8f64;
}
.diff-block.rejected {
border-color: #9a3d48;
}
.diff-block-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid var(--line);
background: #12284b;
}
.diff-block-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 12px;
}
.diff-block-controls {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.diff-block-body {
display: block;
}
.diff-block.collapsed .diff-block-body {
display: none;
}
.diff-block-actions > button {
height: 26px;
padding: 0 10px;
font-size: 12px;
}
.diff-block-actions > button.active {
border-color: var(--accent);
} }
.diff-line { .diff-line {
display: grid; display: block;
grid-template-columns: auto 1fr;
gap: 8px;
font-family: "IBM Plex Mono", "Consolas", monospace; font-family: "IBM Plex Mono", "Consolas", monospace;
font-size: 12px; font-size: 12px;
padding: 2px 8px; padding: 2px 8px;
white-space: pre-wrap;
} }
.diff-line.add { .diff-line.add {
@@ -617,20 +832,64 @@ textarea {
background: #3a2027; background: #3a2027;
} }
.diff-line.context {
color: var(--muted);
background: #0b1830;
}
.tree-item { .tree-item {
padding: 2px 4px; padding: 2px 4px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
border-radius: 4px;
} }
.tree-item:hover { .tree-item:hover {
background: #133056; background: #133056;
} }
.tree-item-selected {
background: #183563;
}
.tree-item-dir { .tree-item-dir {
color: var(--muted); color: var(--muted);
} }
.tree-context-menu {
position: fixed;
z-index: 10000;
min-width: 220px;
display: flex;
flex-direction: column;
padding: 6px;
gap: 4px;
border: 1px solid var(--line);
border-radius: 8px;
background: #0f2343;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
}
.tree-context-menu > button {
height: 32px;
text-align: left;
padding: 0 10px;
font-size: 13px;
border-radius: 6px;
}
.tree-inline-input {
width: 100%;
height: 28px;
padding: 0 8px;
border: 1px solid var(--accent);
border-radius: 6px;
background: #0b1830;
color: var(--text);
font-size: 13px;
line-height: 1;
}
.indexing-modal { .indexing-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -695,6 +954,26 @@ textarea {
transition: width 0.2s ease; transition: width 0.2s ease;
} }
.indexing-progress.indeterminate .indexing-progress-bar {
width: 35%;
animation: indexing-indeterminate-slide 1s linear infinite;
}
.indexing-actions {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
@keyframes indexing-indeterminate-slide {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(320%);
}
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;