diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 363fb09..dc7bd23 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -157,6 +157,83 @@ приложение работает 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(text)` — добавить строку лога; при превышении лимита старые строки + отбрасываются (буфер ограничен, не расти бесконечно). +- Контейнер виджета — отдаётся окну для встраивания в таб «Логи» (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/Cancel). +- Доставка: окно (как 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) diff --git a/internal/ui/chat_panel.go b/internal/ui/chat_panel.go new file mode 100644 index 0000000..80364b3 --- /dev/null +++ b/internal/ui/chat_panel.go @@ -0,0 +1,39 @@ +package ui + +// ChatPanel — панель «Диалог» (спец 12.12): транскрипт диалога выбранной +// задачи + композитор (поле ввода и кнопки команд). +// +// Транскрипт — буфер ограниченного размера; при превышении лимита старые +// строки отбрасываются. Композитор подключается к ui.Commands через +// SetCommands. Обновление — на потоке Fyne; вызывающий уже внутри fyne.Do. +type ChatPanel interface { + // SetTranscript заменяет содержимое диалога полным рендером. + SetTranscript(text string) + // Append добавляет строку в диалог (буфер ограничен). + Append(text string) + // Clear очищает диалог (задача недоступна/не выбрана). + Clear() + // SetCommands подключает команды к композитору (ввод + кнопки). + SetCommands(c *Commands) +} + +// NilChatPanel — no-op реализация ChatPanel для headless-режима и тестов. +type NilChatPanel struct{} + +// NewNilChatPanel создаёт NilChatPanel. +func NewNilChatPanel() *NilChatPanel { return &NilChatPanel{} } + +func (NilChatPanel) SetTranscript(string) {} +func (NilChatPanel) Append(string) {} +func (NilChatPanel) Clear() {} +func (NilChatPanel) SetCommands(*Commands) {} + +// FormatRole — строка роли в диалоге (pure-функция, спец 12.12). +func FormatRole(role string) string { + switch role { + case "user": + return "👤 " + default: + return "🤖 " + } +} \ No newline at end of file diff --git a/internal/ui/chat_panel_test.go b/internal/ui/chat_panel_test.go new file mode 100644 index 0000000..d076c20 --- /dev/null +++ b/internal/ui/chat_panel_test.go @@ -0,0 +1,26 @@ +package ui + +import "testing" + +// FormatRole — строка роли в диалоге (спец 12.12), pure-функция без Fyne. +func TestFormatRole(t *testing.T) { + if FormatRole("user") != "👤 " { + t.Errorf("user: got %q, want «👤 »", FormatRole("user")) + } + if FormatRole("assistant") != "🤖 " { + t.Errorf("assistant: got %q, want «🤖 »", FormatRole("assistant")) + } + if FormatRole("") != "🤖 " { + t.Errorf("default: got %q, want «🤖 »", FormatRole("")) + } +} + +// NilChatPanel — no-op контракт (спец 12.8): безопасно для headless/тестов. +func TestNilChatPanelNoop(t *testing.T) { + p := NewNilChatPanel() + c := NewCommands(nil) + p.SetTranscript("история") + p.Append("строка") + p.Clear() + p.SetCommands(c) +} \ No newline at end of file diff --git a/internal/ui/desktop/chat_panel.go b/internal/ui/desktop/chat_panel.go new file mode 100644 index 0000000..8c9f007 --- /dev/null +++ b/internal/ui/desktop/chat_panel.go @@ -0,0 +1,87 @@ +//go:build cgo + +package desktop + +import ( + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// chatLimit — обрезка буфера транскрипта диалога (спец 12.12). +const chatLimit = 200_000 + +// ChatPanel — Fyne-реализация ui.ChatPanel (спец 12.8, 12.12): транскрипт +// диалога + композитор (поле ввода и кнопки команд). +type ChatPanel struct { + convLbl *widget.Label + input *widget.Entry + commands *ui.Commands +} + +// NewChatPanel создаёт ChatPanel. +func NewChatPanel() *ChatPanel { + p := &ChatPanel{} + p.convLbl = widget.NewLabel("Выберите задачу.") + p.convLbl.Wrapping = fyne.TextWrapWord + p.input = widget.NewEntry() + p.input.SetPlaceHolder("Сообщение… (Enter — отправить)") + return p +} + +// Transcript возвращает виджет транскрипта (Scroll) для встраивания в окно. +func (p *ChatPanel) Transcript() fyne.CanvasObject { + return container.NewScroll(p.convLbl) +} + +// Composer возвращает виджет композитора (ввод + кнопки команд). +func (p *ChatPanel) Composer() fyne.CanvasObject { + cmdBar := container.NewHBox( + widget.NewButton("Новая", func() { if p.commands != nil { p.commands.Start() } }), + widget.NewButton("Создавай", func() { if p.commands != nil { p.commands.Approve() } }), + widget.NewButton("Пропустить", func() { if p.commands != nil { p.commands.Skip() } }), + widget.NewButton("Отмена", func() { if p.commands != nil { p.commands.Cancel() } }), + ) + return container.NewBorder(nil, nil, cmdBar, p.input, nil) +} + +// SetTranscript заменяет содержимое диалога (спец 12.12). +func (p *ChatPanel) SetTranscript(text string) { + p.convLbl.SetText(text) +} + +// Append добавляет строку в диалог (буфер ограничен). +func (p *ChatPanel) Append(text string) { + s := p.convLbl.Text + text + "\n\n" + if len(s) > chatLimit { + s = s[len(s)-chatLimit:] + } + p.convLbl.SetText(s) +} + +// Clear очищает диалог. +func (p *ChatPanel) Clear() { + p.convLbl.SetText("") +} + +// SetCommands подключает команды к композитору. +func (p *ChatPanel) SetCommands(c *ui.Commands) { + p.commands = c + p.input.OnSubmitted = func(s string) { + s = strings.TrimSpace(s) + if s == "" { + return + } + p.input.SetText("") + if c != nil { + c.SendText(s) + } + } +} + +// compile-time проверка реализации контракта. +var _ ui.ChatPanel = (*ChatPanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/log_panel.go b/internal/ui/desktop/log_panel.go new file mode 100644 index 0000000..001571d --- /dev/null +++ b/internal/ui/desktop/log_panel.go @@ -0,0 +1,49 @@ +//go:build cgo + +package desktop + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных логов. +// +// Внутри — widget.Label с ограниченным буфером (maxLogLen); Append обрезает +// строку до лимита. Вызовы Append приходят из горутин Hub, поэтому обновление +// виджета выполняется через fyne.Do (на потоке Fyne). +type LogPanel struct { + label *widget.Label +} + +// maxLogLen — обрезка буфера панели «Логи», чтобы не расти бесконечно. +const maxLogLen = 200_000 + +// NewLogPanel создаёт LogPanel. +func NewLogPanel() *LogPanel { + l := widget.NewLabel("") + l.Wrapping = fyne.TextWrapWord + return &LogPanel{label: l} +} + +// Widget возвращает контейнер панели для встраивания в окно (таб «Логи»). +func (p *LogPanel) Widget() fyne.CanvasObject { + return container.NewScroll(p.label) +} + +// Append добавляет строку лога (спец 12.9), с ограничением буфера. +func (p *LogPanel) Append(text string) { + fyne.Do(func() { + s := p.label.Text + text + "\n" + if len(s) > maxLogLen { + s = s[len(s)-maxLogLen:] + } + p.label.SetText(s) + }) +} + +// compile-time проверка реализации контракта. +var _ ui.LogPanel = (*LogPanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/state_panel.go b/internal/ui/desktop/state_panel.go new file mode 100644 index 0000000..bb42614 --- /dev/null +++ b/internal/ui/desktop/state_panel.go @@ -0,0 +1,50 @@ +//go:build cgo + +package desktop + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// stateLimit — обрезка буфера панели «Состояние» (спец 12.13). +const stateLimit = 200_000 + +// StatePanel — Fyne-реализация ui.StatePanel (спец 12.8, 12.13): агенты и +// трейсы выбранной задачи (таб «Состояние»). +type StatePanel struct { + stateLbl *widget.Label +} + +// NewStatePanel создаёт StatePanel. +func NewStatePanel() *StatePanel { + p := &StatePanel{} + p.stateLbl = widget.NewLabel("") + p.stateLbl.Wrapping = fyne.TextWrapWord + return p +} + +// Widget возвращает контейнер панели для встраивания в таб «Состояние». +func (p *StatePanel) Widget() fyne.CanvasObject { + return container.NewScroll(p.stateLbl) +} + +// SetText заменяет содержимое снимком трасс (спец 12.13). +func (p *StatePanel) SetText(text string) { + p.stateLbl.SetText(text) +} + +// Append добавляет live-строку (буфер ограничен). +func (p *StatePanel) Append(text string) { + s := p.stateLbl.Text + text + "\n" + if len(s) > stateLimit { + s = s[len(s)-stateLimit:] + } + p.stateLbl.SetText(s) +} + +// compile-time проверка реализации контракта. +var _ ui.StatePanel = (*StatePanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/task_detail_panel.go b/internal/ui/desktop/task_detail_panel.go new file mode 100644 index 0000000..9931972 --- /dev/null +++ b/internal/ui/desktop/task_detail_panel.go @@ -0,0 +1,54 @@ +//go:build cgo + +package desktop + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "github.com/kamelion/ratatoskr-go/internal/storage" + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// TaskDetailPanel — Fyne-реализация ui.TaskDetailPanel (спец 12.8, 12.11): +// детали выбранной задачи (заголовок, статус, детали) — верх right-сплита. +type TaskDetailPanel struct { + titleLbl *widget.Label + statusLbl *widget.Label + detailLbl *widget.Label +} + +// NewTaskDetailPanel создаёт TaskDetailPanel. +func NewTaskDetailPanel() *TaskDetailPanel { + p := &TaskDetailPanel{} + p.titleLbl = widget.NewLabel("—") + p.titleLbl.TextStyle = fyne.TextStyle{Bold: true} + p.statusLbl = widget.NewLabel("") + p.statusLbl.TextStyle = fyne.TextStyle{Italic: true} + p.detailLbl = widget.NewLabel("") + p.detailLbl.Wrapping = fyne.TextWrapWord + return p +} + +// Widget возвращает контейнер панели для встраивания в окно (верх right-сплита). +func (p *TaskDetailPanel) Widget() fyne.CanvasObject { + return container.NewVBox(p.titleLbl, p.statusLbl, p.detailLbl) +} + +// ShowTask рендерит детали из снимка задачи (спец 12.11). +func (p *TaskDetailPanel) ShowTask(t storage.Task) { + p.titleLbl.SetText(t.Title) + p.statusLbl.SetText(string(t.Status)) + p.detailLbl.SetText(ui.TaskDetailText(t)) +} + +// ShowEmpty сбрасывает к placeholder при недоступной задаче. +func (p *TaskDetailPanel) ShowEmpty() { + p.titleLbl.SetText("—") + p.statusLbl.SetText("") + p.detailLbl.SetText("") +} + +// compile-time проверка реализации контракта. +var _ ui.TaskDetailPanel = (*TaskDetailPanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/task_list_panel.go b/internal/ui/desktop/task_list_panel.go new file mode 100644 index 0000000..1daed2d --- /dev/null +++ b/internal/ui/desktop/task_list_panel.go @@ -0,0 +1,78 @@ +//go:build cgo + +package desktop + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/widget" + + "github.com/kamelion/ratatoskr-go/internal/storage" + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// TaskListPanel — Fyne-реализация ui.TaskListPanel (спец 12.8, 12.10): список +// задач (левая панель окна). +// +// Внутри — widget.List над собственным буфером []storage.Task (снимки из Store). +// Состояние выбора живёт в окне: окно зовёт Select, чтобы подсветить строку. +type TaskListPanel struct { + list *widget.List + tasks []storage.Task +} + +// NewTaskListPanel создаёт TaskListPanel. +func NewTaskListPanel() *TaskListPanel { + p := &TaskListPanel{} + p.list = widget.NewList( + func() int { return len(p.tasks) }, + func() fyne.CanvasObject { + return widget.NewLabel("loading…") + }, + func(id widget.ListItemID, o fyne.CanvasObject) { + if id < 0 || id >= len(p.tasks) { + return + } + o.(*widget.Label).SetText(ui.TaskTitle(p.tasks[id])) + }, + ) + return p +} + +// Widget возвращает виджет списка для встраивания в окно (левая панель). +func (p *TaskListPanel) Widget() fyne.CanvasObject { + return p.list +} + +// SetTasks заменяет снимок списка и перерисовывает виджет (спец 12.10). +func (p *TaskListPanel) SetTasks(tasks []storage.Task) { + p.tasks = tasks + p.list.Refresh() +} + +// Select подсвечивает строку задачи id; если её нет — снимает выделение. +func (p *TaskListPanel) Select(id int64) { + idx := -1 + for i, t := range p.tasks { + if t.ID == id { + idx = i + break + } + } + if idx < 0 { + p.list.UnselectAll() + return + } + p.list.Select(widget.ListItemID(idx)) +} + +// OnSelect регистрирует колбэк клика по строке (спец 12.10). +func (p *TaskListPanel) OnSelect(fn func(id int64)) { + p.list.OnSelected = func(id widget.ListItemID) { + if fn != nil && id >= 0 && id < len(p.tasks) { + fn(p.tasks[id].ID) + } + } +} + +// compile-time проверка реализации контракта. +var _ ui.TaskListPanel = (*TaskListPanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go index cd21481..5ea5361 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -10,7 +10,6 @@ import ( "context" "log" "strings" - "sync" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -20,13 +19,10 @@ import ( "github.com/kamelion/ratatoskr-go/internal/chat" "github.com/kamelion/ratatoskr-go/internal/events" - "github.com/kamelion/ratatoskr-go/internal/model" - "github.com/kamelion/ratatoskr-go/internal/storage" "github.com/kamelion/ratatoskr-go/internal/ui" ) const ( - maxLogLen = 200_000 // обрезка буферов панелей, чтобы не расти бесконечно selectedPref = "task.selected" splitHPref = "split.h" splitVPref = "split.v" @@ -41,20 +37,15 @@ type Window struct { handler chat.Handler - // состояние списка - mu sync.Mutex // защищает tasks - tasks []storage.Task + // состояние выбора (живёт в окне, спец 12.10) selected int64 // виджеты - list *widget.List - titleLbl *widget.Label - statusLbl *widget.Label - detailLbl *widget.Label - convLbl *widget.Label - logsLbl *widget.Label - stateLbl *widget.Label - input *widget.Entry + taskList *TaskListPanel + detail *TaskDetailPanel + chat *ChatPanel + state *StatePanel + logs *LogPanel hsplit *container.Split vsplit *container.Split @@ -87,48 +78,18 @@ func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn } // build собирает виджеты и раскладку. func (w *Window) build() { - w.titleLbl = widget.NewLabel("—") - w.titleLbl.TextStyle = fyne.TextStyle{Bold: true} - w.statusLbl = widget.NewLabel("") - w.statusLbl.TextStyle = fyne.TextStyle{Italic: true} - w.detailLbl = widget.NewLabel("") - w.detailLbl.Wrapping = fyne.TextWrapWord + w.detail = NewTaskDetailPanel() - w.convLbl = widget.NewLabel("Выберите задачу.") - w.convLbl.Wrapping = fyne.TextWrapWord + w.chat = NewChatPanel() + w.chat.SetCommands(w.commands) - w.logsLbl = widget.NewLabel("") - w.logsLbl.Wrapping = fyne.TextWrapWord + w.state = NewStatePanel() - w.stateLbl = widget.NewLabel("") - w.stateLbl.Wrapping = fyne.TextWrapWord + w.logs = NewLogPanel() // Список задач (слева) - w.list = widget.NewList( - func() int { w.mu.Lock(); defer w.mu.Unlock(); return len(w.tasks) }, - func() fyne.CanvasObject { - return widget.NewLabel("loading…") - }, - func(id widget.ListItemID, o fyne.CanvasObject) { - w.mu.Lock() - defer w.mu.Unlock() - if id < 0 || id >= len(w.tasks) { - return - } - t := w.tasks[id] - o.(*widget.Label).SetText(taskTitle(t)) - }, - ) - w.list.OnSelected = func(id widget.ListItemID) { - w.mu.Lock() - if id < 0 || id >= len(w.tasks) { - w.mu.Unlock() - return - } - t := w.tasks[id] - w.mu.Unlock() - w.selectTask(t.ID) - } + w.taskList = NewTaskListPanel() + w.taskList.OnSelect(func(id int64) { w.selectTask(id) }) quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() { w.saveLayout() @@ -136,42 +97,23 @@ func (w *Window) build() { w.onQuit() } }) - left := container.NewBorder(nil, quitBtn, nil, nil, w.list) + left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget()) // Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане). - details := container.NewVBox(w.titleLbl, w.statusLbl, w.detailLbl) - convScroll := container.NewScroll(w.convLbl) + details := w.detail.Widget() right := container.NewVSplit( container.NewScroll(details), - convScroll, + w.chat.Transcript(), ) w.vsplit = right // Нижняя панель: вкладки «Логи» + «Состояние». bottomTabs := container.NewAppTabs( - container.NewTabItem("Логи", container.NewScroll(w.logsLbl)), - container.NewTabItem("Состояние", container.NewScroll(w.stateLbl)), + container.NewTabItem("Логи", w.logs.Widget()), + container.NewTabItem("Состояние", w.state.Widget()), ) - // Ввод + кнопки команд - w.input = widget.NewEntry() - w.input.SetPlaceHolder("Сообщение… (Enter — отправить)") - w.input.OnSubmitted = func(s string) { - s = strings.TrimSpace(s) - if s == "" { - return - } - w.input.SetText("") - w.commands.SendText(s) - } - cmdBar := container.NewHBox( - widget.NewButton("Новая", func() { w.commands.Start() }), - widget.NewButton("Создавай", func() { w.commands.Approve() }), - widget.NewButton("Пропустить", func() { w.commands.Skip() }), - widget.NewButton("Отмена", func() { w.commands.Cancel() }), - ) - inputRow := container.NewBorder(nil, nil, cmdBar, w.input, nil) - bottom := container.NewBorder(nil, inputRow, nil, nil, bottomTabs) + bottom := container.NewBorder(nil, w.chat.Composer(), nil, nil, bottomTabs) w.hsplit = container.NewHSplit(left, right) @@ -195,36 +137,20 @@ func (w *Window) saveLayout() { pref.SetFloat(splitVPref, w.vsplit.Offset) } -// taskTitle — строка в списке задач. -func taskTitle(t storage.Task) string { - title := t.Title - if title == "" { - title = "(без названия)" - } - return title + "\n" + string(t.Status) -} - // selectTask загружает детали выбранной задачи (снимки из Store). func (w *Window) selectTask(id int64) { w.selected = id + w.taskList.Select(id) w.fyneApp.Preferences().SetInt(selectedPref, int(id)) ctx := context.Background() t, err := w.store.GetTask(ctx, id) if err != nil { - w.titleLbl.SetText("—") - w.statusLbl.SetText("") - w.detailLbl.SetText("") - w.convLbl.SetText("") + w.detail.ShowEmpty() + w.chat.Clear() return } - w.titleLbl.SetText(t.Title) - w.statusLbl.SetText(string(t.Status)) - detail := "Цель: " + t.Goal - if len(t.Repos) > 0 { - detail += "\nРепозитории: " + strings.Join(t.Repos, ", ") - } - w.detailLbl.SetText(detail) + w.detail.ShowTask(t) // Диалог: история + краткие трассы. hist, err := w.store.GetHistory(ctx, id) @@ -233,7 +159,7 @@ func (w *Window) selectTask(id int64) { } var b strings.Builder for _, h := range hist { - b.WriteString(formatRole(h.Role)) + b.WriteString(ui.FormatRole(h.Role)) b.WriteString(h.Content) b.WriteString("\n\n") } @@ -246,39 +172,19 @@ func (w *Window) selectTask(id int64) { if b.Len() == 0 { b.WriteString("Нет сообщений. /start — начать задачу.") } - w.convLbl.SetText(b.String()) + w.chat.SetTranscript(b.String()) // Состояние: сброс к снимку задач. w.refreshState(ctx, id) } -func formatRole(role string) string { - switch role { - case "user": - return "👤 " - default: - return "🤖 " - } -} - // refreshState — снимок «Состояния» выбранной задачи (агенты + трейсы). func (w *Window) refreshState(ctx context.Context, id int64) { traces, err := w.store.GetTraces(ctx, id) if err != nil { return } - var b strings.Builder - for _, tr := range traces { - b.WriteString(strings.ToUpper(tr.Agent) + ": " + string(tr.Status)) - if tr.SessionID != "" { - b.WriteString(" (session " + tr.SessionID + ")") - } - b.WriteString("\n") - } - if b.Len() == 0 { - b.WriteString("Нет активных агентов.") - } - w.stateLbl.SetText(b.String()) + w.state.SetText(ui.StateText(traces)) } // refreshList перечитывает список задач из Store (снимок). @@ -289,42 +195,13 @@ func (w *Window) refreshList() { log.Printf("ui: list tasks: %v", err) return } - w.mu.Lock() - w.tasks = tasks - selected := w.selected - w.mu.Unlock() - - if w.list != nil { - w.list.Refresh() - } - _ = selected + w.taskList.SetTasks(tasks) + w.taskList.Select(w.selected) } -// appendConv добавляет строку в диалог (буфер ограничен). -func (w *Window) appendConv(text string) { - s := w.convLbl.Text + text + "\n\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] - } - w.convLbl.SetText(s) -} - -// appendLog добавляет строку в панель «Логи». +// appendLog добавляет строку в панель «Логи» (делегирует в LogPanel). func (w *Window) appendLog(text string) { - s := w.logsLbl.Text + text + "\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] - } - w.logsLbl.SetText(s) -} - -// appendState добавляет строку в панель «Состояние». -func (w *Window) appendState(text string) { - s := w.stateLbl.Text + text + "\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] - } - w.stateLbl.SetText(s) + w.logs.Append(text) } // submitText отправляет ввод пользователя в Router. @@ -368,7 +245,7 @@ func (w *Window) Send(_ context.Context, _ chat.Address, m chat.Message) error { } text = sb.String() } - fyne.Do(func() { w.appendConv("🤖 " + text) }) + fyne.Do(func() { w.chat.Append("🤖 " + text) }) return nil } @@ -378,7 +255,7 @@ func (w *Window) Ask(_ context.Context, _ chat.Address, m chat.Message) error { for _, opt := range m.Options { text += "\n" + opt.Label } - fyne.Do(func() { w.appendConv("❓ " + text) }) + fyne.Do(func() { w.chat.Append("❓ " + text) }) return nil } @@ -425,73 +302,30 @@ func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) { if w.selected == e.ID { w.selectTask(e.ID) } - w.appendState(taskStatusLine(e)) + w.state.Append(ui.TaskStatusText(e)) }) } func (w *Window) OnHistoryAppended(e events.HistoryAppended) { fyne.Do(func() { if w.selected == e.TaskID { - w.appendConv(formatRole(e.Role) + e.Content) + w.chat.Append(ui.FormatRole(e.Role) + e.Content) } }) } func (w *Window) OnTraceAppended(e events.TraceAppended) { fyne.Do(func() { - w.appendState(strings.ToUpper(e.Agent) + ": " + string(e.Status)) + w.state.Append(ui.TraceLine(e.Agent, e.Status)) }) } func (w *Window) OnAgentActivity(e events.AgentActivity) { fyne.Do(func() { - w.appendState(strings.ToUpper(e.Agent) + " → " + e.Stage) + w.state.Append(ui.ActivityLine(e.Agent, e.Stage)) }) } func (w *Window) OnLog(e events.LogLine) { - fyne.Do(func() { w.appendLog(e.Text) }) -} - -func taskStatusLine(e events.TaskStatusChanged) string { - return statusBadge(e.To) + " #" + itoa(e.ID) + ": " + string(e.From) + " → " + string(e.To) -} - -func statusBadge(s model.Status) string { - switch s { - case model.StatusSuccess: - return "✅" - case model.StatusFailed, model.StatusAborted: - return "❌" - case model.StatusRunning, model.StatusCollecting: - return "⏳" - case model.StatusReady, model.StatusApproved: - return "🟡" - case model.StatusCancelled: - return "🚫" - default: - return "•" - } -} - -func itoa(v int64) string { - if v == 0 { - return "0" - } - neg := v < 0 - if neg { - v = -v - } - var b [24]byte - i := len(b) - for v > 0 { - i-- - b[i] = byte('0' + v%10) - v /= 10 - } - if neg { - i-- - b[i] = '-' - } - return string(b[i:]) + w.appendLog(e.Text) } diff --git a/internal/ui/log_panel.go b/internal/ui/log_panel.go new file mode 100644 index 0000000..65fafb0 --- /dev/null +++ b/internal/ui/log_panel.go @@ -0,0 +1,29 @@ +// Package ui — контроллер и представления десктопного интерфейса (Fyne). +// +// Слой обмена с ядром: однонаправленный поток (спец 12.2–12.5). +// - Core мутирует состояние; UI только читает снимки и реагирует на события. +// - Действия UI = команды (CreateTask/ApproveTask/...), которые зовут Core. +// - Подписчик шины (Controller) получает события и перекладывает их в View +// (реализация Fyne) через fyne.Do — никаких прямых вызовов Fyne из core. +// +// Контракты компонентов-виджетов (спец 12.8): интерфейс + Nil-реализация. +package ui + +// LogPanel — панель «Логи» (спец 12.9): поток системных логов (сырые строки). +// +// Содержит строки лога в буфере ограниченного размера; при превышении лимита +// старые строки отбрасываются. Контейнер виджета отдаётся окну для встраивания +// в таб «Логи» — реализация (Fyne) сама владеет своим виджетом. +type LogPanel interface { + // Append добавляет строку лога. Реализация обязана перекладывать работу + // на поток Fyne (fyne.Do / fyne.DoAndWait), т.к. вызывается из горутин Hub. + Append(text string) +} + +// NilLogPanel — no-op реализация LogPanel для headless-режима и тестов. +type NilLogPanel struct{} + +// NewNilLogPanel создаёт NilLogPanel. +func NewNilLogPanel() *NilLogPanel { return &NilLogPanel{} } + +func (NilLogPanel) Append(string) {} \ No newline at end of file diff --git a/internal/ui/log_panel_test.go b/internal/ui/log_panel_test.go new file mode 100644 index 0000000..5a84ec5 --- /dev/null +++ b/internal/ui/log_panel_test.go @@ -0,0 +1,12 @@ +package ui + +import "testing" + +// NilLogPanel — no-op контракт (спец 12.8): Append ничего не делает, безопасно +// вызывается из горутин Hub и из тестов без Fyne. +func TestNilLogPanelAppendNoop(t *testing.T) { + p := NewNilLogPanel() + p.Append("line 1") + p.Append("") + p.Append("line 3") +} \ No newline at end of file diff --git a/internal/ui/state_panel.go b/internal/ui/state_panel.go new file mode 100644 index 0000000..23a8ff9 --- /dev/null +++ b/internal/ui/state_panel.go @@ -0,0 +1,99 @@ +package ui + +import ( + "strings" + + "github.com/kamelion/ratatoskr-go/internal/events" + "github.com/kamelion/ratatoskr-go/internal/model" + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// StatePanel — панель «Состояние» (спец 12.13): агенты и трейсы выбранной +// задачи. Снимок (SetText) + live-строки (Append), буфер ограничен. +type StatePanel interface { + // SetText заменяет содержимое снимком трасс. + SetText(text string) + // Append добавляет live-строку (буфер ограничен). + Append(text string) +} + +// NilStatePanel — no-op реализация StatePanel для headless-режима и тестов. +type NilStatePanel struct{} + +// NewNilStatePanel создаёт NilStatePanel. +func NewNilStatePanel() *NilStatePanel { return &NilStatePanel{} } + +func (NilStatePanel) SetText(string) {} +func (NilStatePanel) Append(string) {} + +// StateText — снимок трасс задачи (pure-функция, спец 12.13). +func StateText(traces []storage.Trace) string { + var b strings.Builder + for _, tr := range traces { + b.WriteString(strings.ToUpper(tr.Agent) + ": " + string(tr.Status)) + if tr.SessionID != "" { + b.WriteString(" (session " + tr.SessionID + ")") + } + b.WriteString("\n") + } + if b.Len() == 0 { + b.WriteString("Нет активных агентов.") + } + return b.String() +} + +// TraceLine — строка добавленного/обновлённого трейса субагента. +func TraceLine(agent string, status model.TraceStatus) string { + return strings.ToUpper(agent) + ": " + string(status) +} + +// ActivityLine — строка смены этапа агента. +func ActivityLine(agent, stage string) string { + return strings.ToUpper(agent) + " → " + stage +} + +// TaskStatusText — строка изменения статуса задачи (бейдж + ID). +func TaskStatusText(e events.TaskStatusChanged) string { + return StatusBadge(e.To) + " #" + Itoa(e.ID) + ": " + string(e.From) + " → " + string(e.To) +} + +// StatusBadge — эмодзи-бейдж статуса задачи. +func StatusBadge(s model.Status) string { + switch s { + case model.StatusSuccess: + return "✅" + case model.StatusFailed, model.StatusAborted: + return "❌" + case model.StatusRunning, model.StatusCollecting: + return "⏳" + case model.StatusReady, model.StatusApproved: + return "🟡" + case model.StatusCancelled: + return "🚫" + default: + return "•" + } +} + +// Itoa — int64 в строку без зависимостей. +func Itoa(v int64) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var b [24]byte + i := len(b) + for v > 0 { + i-- + b[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} \ No newline at end of file diff --git a/internal/ui/state_panel_test.go b/internal/ui/state_panel_test.go new file mode 100644 index 0000000..dcc699e --- /dev/null +++ b/internal/ui/state_panel_test.go @@ -0,0 +1,50 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/kamelion/ratatoskr-go/internal/events" + "github.com/kamelion/ratatoskr-go/internal/model" + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// StateText — снимок трасс (спец 12.13), pure-функция без Fyne. +func TestStateText(t *testing.T) { + got := StateText(nil) + if !strings.Contains(got, "Нет активных агентов.") { + t.Errorf("пусто: got %q", got) + } + + tr := storage.Trace{Agent: "analyst", Status: model.TraceRunning, SessionID: "s1"} + got = StateText([]storage.Trace{tr}) + if !strings.Contains(got, "ANALYST: running") { + t.Errorf("агент/статус не попали: %q", got) + } + if !strings.Contains(got, "session s1") { + t.Errorf("session не попала: %q", got) + } +} + +// Live-строки — чистые хелперы. +func TestStateLines(t *testing.T) { + if TraceLine("analyst", model.TraceRunning) != "ANALYST: running" { + t.Errorf("TraceLine: %q", TraceLine("analyst", model.TraceRunning)) + } + if ActivityLine("dev", "review") != "DEV → review" { + t.Errorf("ActivityLine: %q", ActivityLine("dev", "review")) + } + + e := events.TaskStatusChanged{ID: 7, From: model.StatusRunning, To: model.StatusSuccess} + got := TaskStatusText(e) + if !strings.Contains(got, "✅") || !strings.Contains(got, "#7") { + t.Errorf("TaskStatusText: %q", got) + } +} + +// NilStatePanel — no-op контракт (спец 12.8): безопасно для headless/тестов. +func TestNilStatePanelNoop(t *testing.T) { + p := NewNilStatePanel() + p.SetText("снимок") + p.Append("строка") +} \ No newline at end of file diff --git a/internal/ui/task_detail.go b/internal/ui/task_detail.go new file mode 100644 index 0000000..be37e26 --- /dev/null +++ b/internal/ui/task_detail.go @@ -0,0 +1,37 @@ +package ui + +import ( + "strings" + + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// TaskDetailPanel — панель «Детали задачи» (спец 12.11): заголовок, статус, +// детали (цель + репозитории) выбранной задачи. +// +// Данные — снимки-копии из Store (спец 12.5). Обновление — на потоке Fyne; +// вызывающий уже внутри fyne.Do. Диалог и «Состояние» — отдельные панели. +type TaskDetailPanel interface { + // ShowTask рендерит детали из снимка задачи. + ShowTask(t storage.Task) + // ShowEmpty сбрасывает к placeholder («—» / пусто) при недоступной задаче. + ShowEmpty() +} + +// NilTaskDetailPanel — no-op реализация TaskDetailPanel для headless-режима и тестов. +type NilTaskDetailPanel struct{} + +// NewNilTaskDetailPanel создаёт NilTaskDetailPanel. +func NewNilTaskDetailPanel() *NilTaskDetailPanel { return &NilTaskDetailPanel{} } + +func (NilTaskDetailPanel) ShowTask(storage.Task) {} +func (NilTaskDetailPanel) ShowEmpty() {} + +// TaskDetailText — строка деталей задачи (pure-функция, спец 12.11). +func TaskDetailText(t storage.Task) string { + detail := "Цель: " + t.Goal + if len(t.Repos) > 0 { + detail += "\nРепозитории: " + strings.Join(t.Repos, ", ") + } + return detail +} \ No newline at end of file diff --git a/internal/ui/task_detail_test.go b/internal/ui/task_detail_test.go new file mode 100644 index 0000000..fad7a10 --- /dev/null +++ b/internal/ui/task_detail_test.go @@ -0,0 +1,36 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// TaskDetailText — строка деталей задачи (спец 12.11), pure-функция без Fyne. +func TestTaskDetailText(t *testing.T) { + noRepos := storage.Task{Goal: "Понять код"} + got := TaskDetailText(noRepos) + if got != "Цель: Понять код" { + t.Errorf("без repos: got %q", got) + } + if strings.Contains(got, "Репозитории") { + t.Errorf("без repos не должно быть репозиториев: %q", got) + } + + withRepos := storage.Task{Goal: "Рефакторинг", Repos: []string{"a", "b"}} + got = TaskDetailText(withRepos) + if !strings.Contains(got, "Цель: Рефакторинг") { + t.Errorf("цель не попала: %q", got) + } + if !strings.Contains(got, "Репозитории: a, b") { + t.Errorf("репозитории не попали: %q", got) + } +} + +// NilTaskDetailPanel — no-op контракт (спец 12.8): безопасно для headless/тестов. +func TestNilTaskDetailPanelNoop(t *testing.T) { + p := NewNilTaskDetailPanel() + p.ShowTask(storage.Task{ID: 1, Goal: "x"}) + p.ShowEmpty() +} \ No newline at end of file diff --git a/internal/ui/task_list_panel.go b/internal/ui/task_list_panel.go new file mode 100644 index 0000000..a5abcdc --- /dev/null +++ b/internal/ui/task_list_panel.go @@ -0,0 +1,40 @@ +package ui + +import ( + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// TaskListPanel — панель «Список задач» (спец 12.10): все задачи строками. +// +// Данные — снимки-копии из Store (спец 12.5), заменяются через SetTasks. +// Состояние выбора живёт в окне: окно зовёт Select, чтобы подсветить строку. +// Контейнер виджета отдаётся окну — реализация (Fyne) сама владеет виджетом. +type TaskListPanel interface { + // SetTasks заменяет снимок списка и перерисовывает виджет. + SetTasks(tasks []storage.Task) + // Select подсвечивает строку задачи id (управляется окном). + Select(id int64) + // OnSelect регистрирует колбэк клика по строке (окно грузит детали). + OnSelect(fn func(id int64)) +} + +// NilTaskListPanel — no-op реализация TaskListPanel для headless-режима и тестов. +type NilTaskListPanel struct { + onSelect func(id int64) +} + +// NewNilTaskListPanel создаёт NilTaskListPanel. +func NewNilTaskListPanel() *NilTaskListPanel { return &NilTaskListPanel{} } + +func (p *NilTaskListPanel) SetTasks([]storage.Task) {} +func (p *NilTaskListPanel) Select(int64) {} +func (p *NilTaskListPanel) OnSelect(fn func(int64)) { p.onSelect = fn } + +// TaskTitle — строка задачи в списке (pure-функция, спец 12.10). +func TaskTitle(t storage.Task) string { + title := t.Title + if title == "" { + title = "(без названия)" + } + return title + "\n" + string(t.Status) +} \ No newline at end of file diff --git a/internal/ui/task_list_panel_test.go b/internal/ui/task_list_panel_test.go new file mode 100644 index 0000000..471cbca --- /dev/null +++ b/internal/ui/task_list_panel_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// TaskTitle — строка в списке задач (спец 12.10), pure-функция без Fyne. +func TestTaskTitle(t *testing.T) { + empty := storage.Task{Status: storage.StatusReady} + got := TaskTitle(empty) + if !strings.Contains(got, "(без названия)") { + t.Errorf("пустое название: got %q, want «(без названия)»", got) + } + if !strings.Contains(got, string(storage.StatusReady)) { + t.Errorf("строка должна содержать статус, got %q", got) + } + + named := storage.Task{Title: "Тест", Status: storage.StatusRunning} + got = TaskTitle(named) + if !strings.Contains(got, "Тест") { + t.Errorf("название не попало в строку: %q", got) + } + if !strings.Contains(got, string(storage.StatusRunning)) { + t.Errorf("статус не попал в строку: %q", got) + } +} + +// NilTaskListPanel — no-op контракт (спец 12.8): безопасно для headless/тестов. +func TestNilTaskListPanelNoop(t *testing.T) { + p := NewNilTaskListPanel() + p.SetTasks([]storage.Task{{ID: 1}}) + p.Select(1) + + var got []int64 + p.OnSelect(func(id int64) { got = append(got, id) }) + // Nil не вызывает колбэки — нет виджета, но вызовы безопасны. +} \ No newline at end of file