feat/ui-refactor #9
@@ -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)
|
||||
|
||||
50
internal/ui/desktop/state_panel.go
Normal file
50
internal/ui/desktop/state_panel.go
Normal file
@@ -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)
|
||||
@@ -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:])
|
||||
}
|
||||
|
||||
99
internal/ui/state_panel.go
Normal file
99
internal/ui/state_panel.go
Normal file
@@ -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:])
|
||||
}
|
||||
50
internal/ui/state_panel_test.go
Normal file
50
internal/ui/state_panel_test.go
Normal file
@@ -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("строка")
|
||||
}
|
||||
Reference in New Issue
Block a user