From 8ba1a8aa003872f5553fae68c9c6807b17cef9b4 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Thu, 20 Aug 2026 23:54:27 +0500 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=C2=AB?= =?UTF-8?q?=D0=A1=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=C2=BB=20?= =?UTF-8?q?(StatePanel)=20=D0=B8=D0=B7=20=D0=BC=D0=BE=D0=BD=D0=BE=D0=BB?= =?UTF-8?q?=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