From fdd73cab61dca99d0bdb4e10f4d5095d44efd92c Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Thu, 20 Aug 2026 23:26:18 +0500 Subject: [PATCH 1/5] =?UTF-8?q?refactor(ui):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=C2=AB=D0=9B=D0=BE=D0=B3=D0=B8=C2=BB=20(LogPanel)=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BC=D0=BE=D0=BD=D0=BE=D0=BB=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=BA=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спеца 12.8-12.9: интерфейс + Nil в internal/ui/log_panel.go, Fyne-реализация в internal/ui/desktop/log_panel.go, окно использует контракт вместо logsLbl. --- docs/ui-spec.md | 21 ++++++++++++++ internal/ui/desktop/log_panel.go | 49 ++++++++++++++++++++++++++++++++ internal/ui/desktop/window.go | 27 +++++++----------- internal/ui/log_panel.go | 29 +++++++++++++++++++ internal/ui/log_panel_test.go | 12 ++++++++ 5 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 internal/ui/desktop/log_panel.go create mode 100644 internal/ui/log_panel.go create mode 100644 internal/ui/log_panel_test.go diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 363fb09..2bfefe6 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -157,6 +157,27 @@ приложение работает 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. + --- ## Открытые пункты (TODO) 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/window.go b/internal/ui/desktop/window.go index cd21481..089cbe2 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -26,7 +26,7 @@ import ( ) const ( - maxLogLen = 200_000 // обрезка буферов панелей, чтобы не расти бесконечно + bufLimit = 200_000 // обрезка буферов панелей окна (conv/state), чтобы не расти бесконечно selectedPref = "task.selected" splitHPref = "split.h" splitVPref = "split.v" @@ -52,8 +52,8 @@ type Window struct { statusLbl *widget.Label detailLbl *widget.Label convLbl *widget.Label - logsLbl *widget.Label stateLbl *widget.Label + logs *LogPanel input *widget.Entry hsplit *container.Split @@ -97,8 +97,7 @@ func (w *Window) build() { w.convLbl = widget.NewLabel("Выберите задачу.") w.convLbl.Wrapping = fyne.TextWrapWord - w.logsLbl = widget.NewLabel("") - w.logsLbl.Wrapping = fyne.TextWrapWord + w.logs = NewLogPanel() w.stateLbl = widget.NewLabel("") w.stateLbl.Wrapping = fyne.TextWrapWord @@ -149,7 +148,7 @@ func (w *Window) build() { // Нижняя панель: вкладки «Логи» + «Состояние». bottomTabs := container.NewAppTabs( - container.NewTabItem("Логи", container.NewScroll(w.logsLbl)), + container.NewTabItem("Логи", w.logs.Widget()), container.NewTabItem("Состояние", container.NewScroll(w.stateLbl)), ) @@ -303,26 +302,22 @@ func (w *Window) refreshList() { // appendConv добавляет строку в диалог (буфер ограничен). func (w *Window) appendConv(text string) { s := w.convLbl.Text + text + "\n\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] + if len(s) > bufLimit { + s = s[len(s)-bufLimit:] } 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) + w.logs.Append(text) } // appendState добавляет строку в панель «Состояние». func (w *Window) appendState(text string) { s := w.stateLbl.Text + text + "\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] + if len(s) > bufLimit { + s = s[len(s)-bufLimit:] } w.stateLbl.SetText(s) } @@ -450,7 +445,7 @@ func (w *Window) OnAgentActivity(e events.AgentActivity) { } func (w *Window) OnLog(e events.LogLine) { - fyne.Do(func() { w.appendLog(e.Text) }) + w.appendLog(e.Text) } func taskStatusLine(e events.TaskStatusChanged) string { 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 -- 2.49.1 From 6274aad3696cd07fc36d0d6f20cf2b9d1a2a4b14 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Thu, 20 Aug 2026 23:31:08 +0500 Subject: [PATCH 2/5] =?UTF-8?q?refactor(ui):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=C2=AB=D0=A1=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=C2=BB=20(TaskListPanel)=20=D0=B8=D0=B7=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=BD=D0=BE=D0=BB=D0=B8=D1=82=D0=B0=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спеца 12.10: интерфейс + Nil + TaskTitle в internal/ui/task_list_panel.go, Fyne-реализация в internal/ui/desktop/task_list_panel.go. Состояние выбора живёт в окне; выбранная строка подсвечивается (Select/Unselect). --- docs/ui-spec.md | 13 +++++ internal/ui/desktop/task_list_panel.go | 78 ++++++++++++++++++++++++++ internal/ui/desktop/window.go | 58 +++---------------- internal/ui/task_list_panel.go | 40 +++++++++++++ internal/ui/task_list_panel_test.go | 40 +++++++++++++ 5 files changed, 179 insertions(+), 50 deletions(-) create mode 100644 internal/ui/desktop/task_list_panel.go create mode 100644 internal/ui/task_list_panel.go create mode 100644 internal/ui/task_list_panel_test.go diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 2bfefe6..117b8ee 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -178,6 +178,19 @@ - Обновление содержимого — на потоке 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. + --- ## Открытые пункты (TODO) 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 089cbe2..417d7fd 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" @@ -21,7 +20,6 @@ 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" ) @@ -41,13 +39,11 @@ type Window struct { handler chat.Handler - // состояние списка - mu sync.Mutex // защищает tasks - tasks []storage.Task + // состояние выбора (живёт в окне, спец 12.10) selected int64 // виджеты - list *widget.List + taskList *TaskListPanel titleLbl *widget.Label statusLbl *widget.Label detailLbl *widget.Label @@ -103,31 +99,8 @@ func (w *Window) build() { w.stateLbl.Wrapping = fyne.TextWrapWord // Список задач (слева) - 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() @@ -135,7 +108,7 @@ 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) @@ -194,18 +167,10 @@ 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() @@ -288,15 +253,8 @@ 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 добавляет строку в диалог (буфер ограничен). 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 -- 2.49.1 From 4c44956d3e0d9cfb3f5d0dfc94e8e0de8bd97fb9 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Thu, 20 Aug 2026 23:43:52 +0500 Subject: [PATCH 3/5] =?UTF-8?q?refactor(ui):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=C2=AB=D0=94=D0=B5=D1=82=D0=B0=D0=BB=D0=B8=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=C2=BB=20(TaskDetailPanel)=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=BD=D0=BE=D0=BB=D0=B8=D1=82=D0=B0=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спеца 12.11: интерфейс + Nil + TaskDetailText в internal/ui/task_detail.go, Fyne-реализация в internal/ui/desktop/task_detail_panel.go. selectTask делегирует в панель; диалог и «Состояние» — отдельные панели, не трогаем. --- docs/ui-spec.md | 13 ++++++ internal/ui/desktop/task_detail_panel.go | 54 ++++++++++++++++++++++++ internal/ui/desktop/window.go | 25 +++-------- internal/ui/task_detail.go | 37 ++++++++++++++++ internal/ui/task_detail_test.go | 36 ++++++++++++++++ 5 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 internal/ui/desktop/task_detail_panel.go create mode 100644 internal/ui/task_detail.go create mode 100644 internal/ui/task_detail_test.go diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 117b8ee..4b68738 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -191,6 +191,19 @@ - Обновление — на потоке 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), здесь + не участвуют; окно оркестрирует загрузку всех панелей по выбору задачи. + --- ## Открытые пункты (TODO) 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/window.go b/internal/ui/desktop/window.go index 417d7fd..ebedeca 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -44,9 +44,7 @@ type Window struct { // виджеты taskList *TaskListPanel - titleLbl *widget.Label - statusLbl *widget.Label - detailLbl *widget.Label + detail *TaskDetailPanel convLbl *widget.Label stateLbl *widget.Label logs *LogPanel @@ -83,12 +81,7 @@ 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 @@ -111,7 +104,7 @@ func (w *Window) build() { left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget()) // Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане). - details := container.NewVBox(w.titleLbl, w.statusLbl, w.detailLbl) + details := w.detail.Widget() convScroll := container.NewScroll(w.convLbl) right := container.NewVSplit( container.NewScroll(details), @@ -176,19 +169,11 @@ func (w *Window) selectTask(id int64) { ctx := context.Background() t, err := w.store.GetTask(ctx, id) if err != nil { - w.titleLbl.SetText("—") - w.statusLbl.SetText("") - w.detailLbl.SetText("") + w.detail.ShowEmpty() w.convLbl.SetText("") 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) 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 -- 2.49.1 From 5c93642cf80ad13b8564198008a5357d6fff251c Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Thu, 20 Aug 2026 23:48:54 +0500 Subject: [PATCH 4/5] =?UTF-8?q?refactor(ui):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=C2=AB=D0=94=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=C2=BB=20(ChatPanel)?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BC=D0=BE=D0=BD=D0=BE=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BE=D0=BA=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спеца 12.12: интерфейс + Nil + FormatRole в internal/ui/chat_panel.go, Fyne-реализация в internal/ui/desktop/chat_panel.go (транскрипт + композитор). Окно как chat.Channel рендерит Send/Ask/историю через Append; state_panel не трогаем. --- docs/ui-spec.md | 16 ++++++ internal/ui/chat_panel.go | 39 ++++++++++++++ internal/ui/chat_panel_test.go | 26 +++++++++ internal/ui/desktop/chat_panel.go | 87 +++++++++++++++++++++++++++++++ internal/ui/desktop/window.go | 62 +++++----------------- 5 files changed, 180 insertions(+), 50 deletions(-) create mode 100644 internal/ui/chat_panel.go create mode 100644 internal/ui/chat_panel_test.go create mode 100644 internal/ui/desktop/chat_panel.go diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 4b68738..920429f 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -204,6 +204,22 @@ - Диалог и «Состояние» — отдельные панели (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`. + --- ## Открытые пункты (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/window.go b/internal/ui/desktop/window.go index ebedeca..3676783 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -24,7 +24,7 @@ import ( ) const ( - bufLimit = 200_000 // обрезка буферов панелей окна (conv/state), чтобы не расти бесконечно + bufLimit = 200_000 // обрезка буфера панели «Состояние», чтобы не расти бесконечно selectedPref = "task.selected" splitHPref = "split.h" splitVPref = "split.v" @@ -45,10 +45,9 @@ type Window struct { // виджеты taskList *TaskListPanel detail *TaskDetailPanel - convLbl *widget.Label + chat *ChatPanel stateLbl *widget.Label logs *LogPanel - input *widget.Entry hsplit *container.Split vsplit *container.Split @@ -83,8 +82,8 @@ func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn } func (w *Window) build() { w.detail = NewTaskDetailPanel() - w.convLbl = widget.NewLabel("Выберите задачу.") - w.convLbl.Wrapping = fyne.TextWrapWord + w.chat = NewChatPanel() + w.chat.SetCommands(w.commands) w.logs = NewLogPanel() @@ -105,10 +104,9 @@ func (w *Window) build() { // Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане). details := w.detail.Widget() - convScroll := container.NewScroll(w.convLbl) right := container.NewVSplit( container.NewScroll(details), - convScroll, + w.chat.Transcript(), ) w.vsplit = right @@ -118,25 +116,7 @@ func (w *Window) build() { container.NewTabItem("Состояние", container.NewScroll(w.stateLbl)), ) - // Ввод + кнопки команд - 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) @@ -170,7 +150,7 @@ func (w *Window) selectTask(id int64) { t, err := w.store.GetTask(ctx, id) if err != nil { w.detail.ShowEmpty() - w.convLbl.SetText("") + w.chat.Clear() return } w.detail.ShowTask(t) @@ -182,7 +162,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") } @@ -195,21 +175,12 @@ 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) @@ -242,15 +213,6 @@ func (w *Window) refreshList() { w.taskList.Select(w.selected) } -// appendConv добавляет строку в диалог (буфер ограничен). -func (w *Window) appendConv(text string) { - s := w.convLbl.Text + text + "\n\n" - if len(s) > bufLimit { - s = s[len(s)-bufLimit:] - } - w.convLbl.SetText(s) -} - // appendLog добавляет строку в панель «Логи» (делегирует в LogPanel). func (w *Window) appendLog(text string) { w.logs.Append(text) @@ -306,7 +268,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 } @@ -316,7 +278,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 } @@ -370,7 +332,7 @@ func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) { 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) } }) } -- 2.49.1 From 8ba1a8aa003872f5553fae68c9c6807b17cef9b4 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Thu, 20 Aug 2026 23:54:27 +0500 Subject: [PATCH 5/5] =?UTF-8?q?refactor(ui):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=C2=AB=D0=A1=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=C2=BB=20(StatePanel)=20=D0=B8=D0=B7=20=D0=BC=D0=BE=D0=BD=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B0=20=D0=BE=D0=BA=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спеца 12.13: интерфейс + Nil + чистые хелперы (StateText/TraceLine/ ActivityLine/TaskStatusText/StatusBadge/Itoa) в internal/ui/state_panel.go, Fyne-реализация в internal/ui/desktop/state_panel.go. window.go — чистая компоновка: все 5 панелей вынесены как контракты. --- docs/ui-spec.md | 14 +++++ internal/ui/desktop/state_panel.go | 50 +++++++++++++++ internal/ui/desktop/window.go | 82 +++---------------------- internal/ui/state_panel.go | 99 ++++++++++++++++++++++++++++++ internal/ui/state_panel_test.go | 50 +++++++++++++++ 5 files changed, 221 insertions(+), 74 deletions(-) create mode 100644 internal/ui/desktop/state_panel.go create mode 100644 internal/ui/state_panel.go create mode 100644 internal/ui/state_panel_test.go diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 920429f..dc7bd23 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -220,6 +220,20 @@ `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/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/window.go b/internal/ui/desktop/window.go index 3676783..5ea5361 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -19,12 +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/ui" ) const ( - bufLimit = 200_000 // обрезка буфера панели «Состояние», чтобы не расти бесконечно selectedPref = "task.selected" splitHPref = "split.h" splitVPref = "split.v" @@ -46,7 +44,7 @@ type Window struct { taskList *TaskListPanel detail *TaskDetailPanel chat *ChatPanel - stateLbl *widget.Label + state *StatePanel logs *LogPanel hsplit *container.Split @@ -85,10 +83,9 @@ func (w *Window) build() { w.chat = NewChatPanel() w.chat.SetCommands(w.commands) - w.logs = NewLogPanel() + w.state = NewStatePanel() - w.stateLbl = widget.NewLabel("") - w.stateLbl.Wrapping = fyne.TextWrapWord + w.logs = NewLogPanel() // Список задач (слева) w.taskList = NewTaskListPanel() @@ -113,7 +110,7 @@ func (w *Window) build() { // Нижняя панель: вкладки «Логи» + «Состояние». bottomTabs := container.NewAppTabs( container.NewTabItem("Логи", w.logs.Widget()), - container.NewTabItem("Состояние", container.NewScroll(w.stateLbl)), + container.NewTabItem("Состояние", w.state.Widget()), ) bottom := container.NewBorder(nil, w.chat.Composer(), nil, nil, bottomTabs) @@ -187,18 +184,7 @@ func (w *Window) refreshState(ctx context.Context, id int64) { 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 (снимок). @@ -218,15 +204,6 @@ func (w *Window) appendLog(text string) { w.logs.Append(text) } -// appendState добавляет строку в панель «Состояние». -func (w *Window) appendState(text string) { - s := w.stateLbl.Text + text + "\n" - if len(s) > bufLimit { - s = s[len(s)-bufLimit:] - } - w.stateLbl.SetText(s) -} - // submitText отправляет ввод пользователя в Router. func (w *Window) submitText(text string) { if w.handler != nil { @@ -325,7 +302,7 @@ 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)) }) } @@ -339,59 +316,16 @@ func (w *Window) OnHistoryAppended(e events.HistoryAppended) { 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) { 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:]) -} 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 -- 2.49.1