# UI-спека Ratatoskr (Fyne Desktop) Статус: утверждается. Черновик, обсуждаем дальше (архитектура обмена UI <-> Core). Стек: **Go + Fyne** (свежая стабильная версия). Платформа: **Windows** (Linux пока не поддерживаем). --- ## 1. Цель и роль UI 1. UI — **полноценный инструмент** (не только админ-панель): управляет всем, чем управляет Telegram, и становится **основным** интерфейсом. 2. Один пользователь, один человек (без multi-user / мультисессии). 3. UI должен уметь **всё, что умеет Telegram-интерфейс**, и станет основным; Telegram остаётся как один из каналов. 4. UI — **графическая оболочка самого приложения** (одна программа), не отдельный process. 5. Внутри оболочки запускается **ботик сам**; UI — это лишь ещё одна реализация `chat.Channel`. 6. Запуск: **обычный запуск бинаря** → UI. Режим без UI — только по флагу **`--noui`**. - Логика: UI включён по умолчанию, headless — опционально (`--noui`). 7. Трей-иконка не нужна. 8. При закрытии окна — **сворачивать** (не завершать процесс). Для полного выхода — **отдельная кнопка** «Завершить», чтобы бот остановился. 9. Конфиг-секции редактировать в UI пока не нужно; **hot-reload конфига** — желательно, если реализуемо (запланировать как «если возможно — да»). ## 2. Компоновка (layout) - Базовый grid по умолчанию: - **слева — список задач** (широкая колонка), - **по центру/право — рабочая область** (в ней живёт «работа бота» / live-шаги); - рабочая область может **разделяться на сплит-панели** (2×2, не считая левой колонки с задачами). - Табы пока **не делаем** (первый этап). - Панели должны быть **сворачиваемыми** (хотя бы левая колонка с задачами). - Сохранение layout (положение/размеры панелей) — **нужно**, переживает перезапуск приложения (Fyne Preferences). - Окно: **на весь экран по умолчанию**. - Нижняя панель: **вкладки «Логи», «Состояние»**: - **Логи** — ведение логов (поток системных логов); - **Состояние** — живое состояние текущих задач / на каком статусе сейчас задача. - Верхняя панель: **меню**, пока только пункт «О программе» (About → About). - Тема: **тёмная**, без переключателя темы; стартуем с **дефолтной темы Fyne**. - Локаль: **en** (интерфейс на английском). ## 3. Данные задач - Задачи отображаются **списком** слева: **краткое название + служебное** (воркшоп). - Детализация списка (статус/фильтры/поиск/сортировка/по умолчание/пагинация) — **дорабатываем потом**. - В деталях задачи (рабочая область) показываем все перечисленные поля: - Title, Goal, Why, AC (acceptance criteria), Repo(s), Tag (task_tag), статус, CreatedAt/UpdatedAt, вложенные трассы (traces), история чата (history), включая (session id opencode). - История чата из БД (user/assistant) — лента сообщений. - Трейсы агентов (аналист/dev/reviewer) — дерево шагов/ходов + raw output. - Live-шаги (LiveRegistry) — восстановить и показывать в реальном времени. ## 4. Действия с задачами - UI поддерживает все действия, доступные в TG: создание задачи, изменение, **approve**, **rework доработка**, **cancel**, **retry**, закрытие (skip/continue), статусы — в рамках валидных переходов. - Создание задачи — форма с полями (Title, Goal/Why, Repos, AC) (какие обязательные — уточнить). - Работа с статусами: эмуляция команд `/approve`, `/retry`, `/cancel` и т.п. (валидные переходы по state machine) - Подтверждения опасных действий (удаление задачи, и т.п.). - Редактирование полей (repo, AC, tags) из UI — по необходимости. ## 7. Мониторинг системы - Показывать **логи** (готовый поток из `log`), **фреймы состояния** бота: - статус opencode serve/pool, коннекции, активные сессии. - Live-статус задач (`/status N`) эквивалентом — во вкладке «Состояние». - Нужен индикатор занятости агента («опенкод думает/dev writing…»). ## 8. Настройки / конфиг - Настройки в UI пока **не выводим**; секреты никоим образом не показываем. - Кнопка «Завершить» (выход приложения) — в меню. - Перезапуск бота из UI — пока не надо. ## 9. Живое обновление / консистентность - **Шина событий (event-bus)** — запланирована: UI получает события из ядра (новые задачи, изменение статусов, live-шаги, логи). Явно нужен. - UI и Telegram — **оболочки** вокруг одного ядра; обмен через **интерфейс (абстракцию)**, не зашумляя ядро. - Консистентность между TG и UI при параллельных изменениях — через события ядра; архитектура обмена — см. раздел 12. ## 10. Технические - **Fyne**: свежая стабильная (последняя). - **Абстракция БД**: доступ к storage через **интерфейс** (не прямиком к *storage.Storage) для тестируемости. - Пакет: **`internal/ui`** (+ подпакеты при необходимости). - Тесты: юнит-тесты модели, **без e2e/UI-тестов** пока. - Использование данных: **вся история** (без ограничения по времени). ## 11. Скоуп первого этапа - Минимальный UI, покрывающий **функционал Telegram-интерфейса** (все команды/действия). - Также UI — основной интерфейс (TG остаётся каналом). --- ## 12. Архитектура обмена UI ↔ Core (best practices) ### 12.1. Слои и направление зависимостей ``` ┌──────────────┐ команды → ┌─────────────────┐ заказы/готовое → ┌──────────────┐ │ UI (Fyne) │ ───────────▶ │ Application / │ ─────────────────▶ │ Domain/core │ │ «представ- │ │ UseCase слой │ события (events) │ (model) │ │ ление» │ ◀─────────── │ │ ◀────────────────── │ │ └──────────────┘ события ← └─────────────────┘ └──────────────┘ ``` - **Core** (`internal/core`.ProcessTurn — state-machine) **не трогаем**. Он — источник правды. - **App** (`internal/app`) — use-case/композиция: политика «одна активная задача на чат», `Notify`, роутинг команд. - **UI** — ещё одна реализация `chat.Channel` (параллельно Telegram). Уже есть идеальный «port»: интерфейс `Channel{Run, OnMessage, Send, Ask, Close}`. ### 12.2. UI получает состояние, а не управляет Core'ом (uni-directional data flow) - Core **мутирует состояние** (БД, worker, аналитик). UI — только **читает снимки** и реагирует на события, обновляя своё view-model (не БД и не core). - Действия UI = **команды** (`CreateTask`, `ApproveTask`, `RetryTask`, `CancelTask`, `ContinueTask`), которые вызывают use-case в Core. - Это даёт: единый источник правды, простую отладку, лёгкое тестирование без UI. ### 12.3. Обмен = событийная шина (event bus / pub-sub) - **Core — публикует** доменные события, UI — **подписчик**: - `TaskCreated{id, chatID}` - `TaskStatusChanged{id, from, to}` (ready/approved/running/success/failed/timeout…) - `TaskHistoryAppended{id, role, content}` - `TraceAppended{id, agent, status, output}` - `AgentActivity{id, agent, stage}` (live-шаги, индикаторы занятости) - `LogLine` (системные логи для вкладки «Логи») - **Направление однонаправленное**: core/worker/analyst не знают, кто подписан; никаких прямых вызовов Fyne из core! - Реализация малая: `Bus` с `Subscribe/Unsubscribe/Publish` + buffered channels (`n`-подписчиков или `select`); паттерн уже есть в `chat.Router` (внутренний `incoming` channel). - Сигнатуры событий использовать **и для консистентности** TG↔UI (оба канала — простые подписчики/клиенты шины). ### 12.4. Модель потоков Fyne (v2.6+) и `fyne.Do` - Fyne с v2.6.0 выполняет все события/колбэки **на одной главной goroutine**. - Фоновые goroutine (worker, opencode polling, ход core) **не должны модифицировать UI напрямую**. - Любое обновление виджетов из своей goroutine — через: - `fyne.Do(func(){ widget.X = …; widget.Refresh() })` — очередь в следующий кадр; - `fyne.DoAndWait(...)` — когда надо дождаться завершения; - унифицировать с **binding** (`binding.String`, `binding.List`) — через `fyne.Do` не требуется, но проще явно. - Подписчик шины ставит snapshot в очередь UI-обновления и вызывает `fyne.Do`. ### 12.5. Чтение-модель на UI: снапшоты, не мутабельные указатели - UI получает **копии** записей (Task, history, traces) через абстракцию БД (см. раздел 10). - Никаких референсов на разделяемые контейнеры core; используются лёгкие view-копии (Value-типы/DT-коды) — нет гонок bottom-up. ### 12.6. Жизненный цикл: Core ≠ UI - Core (бот) живёт независимо от окна. Закрытие окна = **сворачивание** (Core продолжает). - Полный выход — только кнопка «Завершить» → `app.Quit()` (Worker.Stop → router.Close → pool.Close → store.Close). - `--noui` → core запускается без создания окна. ### 12.7. Скоуп реализации (поэтапно) - [x] **Фаза 1**: `internal/ui` — Store-абстракция (копии), окно как `chat.Channel`, команды через канал, snapshots из БД. Fyne-реализация — `internal/ui/desktop` (build-tag `cgo`, требует компилятор C для GLFW). - [x] **Фаза 2**: `internal/events` — Bus/Hub, Core публикует события, UI подписывается через `Controller` (статусы, история, live-шаги, логи). - [ ] **Фаза 3**: консистентность TG↔UI через общий маршрутизатор/шину (`UserID`, `chat.Router`). Примечание: окно (Фаза 1) собирается только с cgo; на машинах без C-компилятора приложение работает headless (UI=nil). Реальное окно полноценно проверяется на машине с MinGW-w64/gcc (`go build -ldflags ...`), здесь — только typecheck. ### 12.8. Структура UI-кода: компоненты-виджеты (рефакторинг) `internal/ui` (без Fyne) — контракты + Nil-реализации + юнит-тесты. Fyne-код — только в `internal/ui/desktop/*`, один файл — одна панель. `desktop/window.go` становится чистой компоновкой. Каждый компонент: - **интерфейс** в `internal/ui/.go` (контракт для тестов/headless); - **Nil-реализация** в том же файле (headless/тесты); - **Fyne-реализация** в `internal/ui/desktop/.go`. ### 12.9. Контракт виджета «Логи» (LogPanel) Панель «Логи» — поток системных логов из `events.LogLine` (уровень + строка). - `Append(level, text)` — добавить запись лога; уровень нормализуется к одному из четырёх (`ui.NormalizeLogLevel`: error/warning/info/debug, неизвестные → info); при превышении лимита старые записи отбрасываются (буфер ограничен, не расти бесконечно). - **Фильтр уровней**: над лентой — выпадающий список с чекбоксами (error/warning/info/debug). По умолчанию отмечены все уровни (виден полный лог); показываются только записи выбранных уровней; изменение набора сразу перерисовывает ленту — и по уже загруженным записям, и для вновь поступающих. Если ни один уровень не выбран — лента пуста (без ошибок). - Контейнер виджета — отдаётся окну для встраивания в таб «Логи» (строка фильтра + Scroll с лентой). - Обновление содержимого — на потоке Fyne (внутри панели `fyne.Do`), не из горутины Hub. ### 12.10. Контракт виджета «Список задач» (TaskListPanel) Список задач — левая панель окна: все задачи (`storage.Task`) строками. - `SetTasks(tasks)` — заменить снимок списка и перерисовать виджет. Данные — копии из Store (спец 12.5). - `OnSelect(fn)` — колбэк клика по строке; окно грузит детали выбранной задачи (это `task_detail`, отдельный компонент). Состояние выбора живёт в окне. - Выбранная строка подсвечивается (`Select`/`Unselect`), окно управляет ею. - Строка = `taskTitle(t)` (pure-функция, тестируется без Fyne). - Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`, панель сама виджет не трогает из горутин Hub. ### 12.11. Контракт виджета «Детали задачи» (TaskDetailPanel) Детали выбранной задачи — верх `right`-сплита: заголовок, статус, детали (цель + репозитории). - `ShowTask(t)` — рендер из снимка `storage.Task`: заголовок (bold), статус (italic), детали = `TaskDetailText(t)`. - `ShowEmpty()` — сброс к placeholder («—» / пусто) при недоступной задаче. - Строка деталей = pure `TaskDetailText(t)` (цель + репо), тестируется без Fyne. - Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`. - Диалог и «Состояние» — отдельные панели (chat_panel, state_panel), здесь не участвуют; окно оркестрирует загрузку всех панелей по выбору задачи. ### 12.12. Контракт виджета «Диалог» (ChatPanel) Диалог — транскрипт общения с выбранной задачей + композитор (поле ввода и кнопки команд). - Транскрипт: - `SetTranscript(text)` — полный рендер истории выбранной задачи; - `Append(text)` — добавить строку; при превышении лимита старые строки отбрасываются (буфер ограничен); - `Clear()` — очистить при недоступной задаче. - Композитор: поле ввода + кнопки команд, подключённые к `ui.Commands` через `SetCommands(c)` (ввод → `SendText`, кнопки → Start/Approve/Skip/Retry/Cancel). - Кнопка «Перезапустить» — полный аналог Telegram-команды `/retry N`: отправляет `/retry N` тем же путём, что и ручной ввод (Commands → Router → Core). Активна только для вкладки с привязанной задачей; окно сообщает ID задачи через `SetBoundTask(taskID)` (при создании вкладки в `addTab` и при привязке свободной вкладки в `bindTaskSession`); `0` — кнопка неактивна. - Доставка: окно (как chat.Channel) рендерит `Send`/`Ask`/историю через `Append`; строка роли = pure `FormatRole(role)` (👤/🤖). - Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`. ### 12.13. Контракт виджета «Состояние» (StatePanel) Панель «Состояние» — агенты + трейсы выбранной задачи: снимок и live-строки. - `SetText(text)` — полный рендер снимка трасс выбранной задачи. - `Append(text)` — добавить строку; при превышении лимита старые строки отбрасываются (буфер ограничен). - Строка снимка = pure `StateText(traces)` (агент: статус, session). - Live-строки (чистые хелперы, окно зовёт `Append` с готовой строкой): - `TraceLine(agent, status)` — добавлен/обновлён трейс субагента; - `ActivityLine(agent, stage)` — смена этапа агента; - `TaskStatusText(e)` — изменение статуса задачи (бейдж + ID). - Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`. --- ## Открытые пункты (TODO) - [ ] Точно определить set сплит-панелей (2x2 центр) и как добавляются - [ ] Обязательные поля формы создания задачи - [ ] Подробности виджета списка задач (после первого мильстон) - [x] Архитектура обмена UI<->Core (раздел 12 — принципы; детальные контракты событий/интерфейсов — следующая итерация)