diff --git a/docs/ui-spec.md b/docs/ui-spec.md index ca28875..f285b80 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -223,7 +223,12 @@ отбрасываются (буфер ограничен); - `Clear()` — очистить при недоступной задаче. - Композитор: поле ввода + кнопки команд, подключённые к `ui.Commands` через - `SetCommands(c)` (ввод → `SendText`, кнопки → Start/Approve/Skip/Cancel). + `SetCommands(c)` (ввод → `SendText`, кнопки → Start/Approve/Skip/Retry/Cancel). +- Кнопка «Перезапустить» — полный аналог Telegram-команды `/retry N`: отправляет + `/retry N` тем же путём, что и ручной ввод (Commands → Router → Core). + Активна только для вкладки с привязанной задачей; окно сообщает ID задачи + через `SetBoundTask(taskID)` (при создании вкладки в `addTab` и при привязке + свободной вкладки в `bindTaskSession`); `0` — кнопка неактивна. - Доставка: окно (как chat.Channel) рендерит `Send`/`Ask`/историю через `Append`; строка роли = pure `FormatRole(role)` (👤/🤖). - Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`. diff --git a/internal/ui/chat_panel.go b/internal/ui/chat_panel.go index 80364b3..c483165 100644 --- a/internal/ui/chat_panel.go +++ b/internal/ui/chat_panel.go @@ -15,6 +15,10 @@ type ChatPanel interface { Clear() // SetCommands подключает команды к композитору (ввод + кнопки). SetCommands(c *Commands) + // SetBoundTask сообщает панели задачу, к которой привязана вкладка + // (0 — свободный чат). Кнопка «Перезапустить» (/retry N) активна только + // для вкладки с привязанной задачей. + SetBoundTask(taskID int64) } // NilChatPanel — no-op реализация ChatPanel для headless-режима и тестов. @@ -27,6 +31,7 @@ func (NilChatPanel) SetTranscript(string) {} func (NilChatPanel) Append(string) {} func (NilChatPanel) Clear() {} func (NilChatPanel) SetCommands(*Commands) {} +func (NilChatPanel) SetBoundTask(int64) {} // FormatRole — строка роли в диалоге (pure-функция, спец 12.12). func FormatRole(role string) string { diff --git a/internal/ui/chat_panel_test.go b/internal/ui/chat_panel_test.go index d076c20..dbeecb0 100644 --- a/internal/ui/chat_panel_test.go +++ b/internal/ui/chat_panel_test.go @@ -23,4 +23,32 @@ func TestNilChatPanelNoop(t *testing.T) { p.Append("строка") p.Clear() p.SetCommands(c) + p.SetBoundTask(7) +} + +// Кнопка «Перезапустить» — полный аналог Telegram-команды /retry N: +// в канал уходит ровно тот же текст, что при ручном вводе команды. +func TestRetryButtonSubmitsSameCommandAsTelegram(t *testing.T) { + var got []string + c := NewCommands(func(text string) { got = append(got, text) }) + + c.Retry(42) + + if len(got) != 1 || got[0] != "/retry 42" { + t.Fatalf("got %v, want [/retry 42]", got) + } +} + +// Свободная вкладка (без задачи): Retry(id<=0) — no-op, ничего не отправляется +// (кнопка «Перезапустить» на такой вкладке неактивна). +func TestRetryWithoutTaskIsNoop(t *testing.T) { + var got []string + c := NewCommands(func(text string) { got = append(got, text) }) + + c.Retry(0) + c.Retry(-5) + + if len(got) != 0 { + t.Fatalf("got %v, want nothing submitted", got) + } } \ No newline at end of file diff --git a/internal/ui/commands.go b/internal/ui/commands.go index bcf202e..5ff92c7 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -30,8 +30,14 @@ func (c *Commands) Cancel() { c.Submit("/cancel") } // Skip — пропустить сбор, сформировать черновик (/skip). func (c *Commands) Skip() { c.Submit("/skip") } -// Retry — перезапустить задачу N (/retry N). -func (c *Commands) Retry(id int64) { c.Submit(fmt.Sprintf("/retry %d", id)) } +// Retry — перезапустить задачу N (/retry N): полный аналог Telegram-команды. +// id <= 0 — no-op (кнопка «Перезапустить» на свободной вкладке неактивна). +func (c *Commands) Retry(id int64) { + if id <= 0 { + return + } + c.Submit(fmt.Sprintf("/retry %d", id)) +} // Status — запросить статус задачи N (/status N). func (c *Commands) Status(id int64) { c.Submit(fmt.Sprintf("/status %d", id)) } diff --git a/internal/ui/desktop/chat_panel.go b/internal/ui/desktop/chat_panel.go index 4288b33..76f1c92 100644 --- a/internal/ui/desktop/chat_panel.go +++ b/internal/ui/desktop/chat_panel.go @@ -18,9 +18,11 @@ const chatLimit = 200_000 // ChatPanel — Fyne-реализация ui.ChatPanel (спец 12.8, 12.12): транскрипт // диалога + композитор (поле ввода и кнопки команд). type ChatPanel struct { - convLbl *widget.Label - input *widget.Entry - commands *ui.Commands + convLbl *widget.Label + input *widget.Entry + commands *ui.Commands + retryBtn *widget.Button // «Перезапустить» — полный аналог /retry N + boundTask int64 // >0 — вкладка привязана к задаче №boundTask } // NewChatPanel создаёт ChatPanel. @@ -30,6 +32,8 @@ func NewChatPanel() *ChatPanel { p.convLbl.Wrapping = fyne.TextWrapWord p.input = widget.NewEntry() p.input.SetPlaceHolder("Сообщение… (Enter — отправить)") + p.retryBtn = widget.NewButton("Перезапустить", p.retryTask) + p.retryBtn.Disable() // активируется при привязке вкладки к задаче (SetBoundTask) return p } @@ -44,11 +48,32 @@ func (p *ChatPanel) Composer() fyne.CanvasObject { 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() } }), + p.retryBtn, widget.NewButton("Отмена", func() { if p.commands != nil { p.commands.Cancel() } }), ) return container.NewBorder(nil, nil, cmdBar, nil, p.input) } +// retryTask — колбэк кнопки «Перезапустить»: отправляет "/retry N" тем же +// путём, что и ввод пользователя (Commands → Router → Core), т.е. серверная +// логика полностью совпадает с Telegram-командой /retry N. +func (p *ChatPanel) retryTask() { + if p.commands != nil { + p.commands.Retry(p.boundTask) + } +} + +// SetBoundTask включает кнопку «Перезапустить», когда вкладка привязана к +// задаче (taskID > 0); для свободной вкладки кнопка неактивна. +func (p *ChatPanel) SetBoundTask(taskID int64) { + p.boundTask = taskID + if taskID > 0 { + p.retryBtn.Enable() + } else { + p.retryBtn.Disable() + } +} + // SetTranscript заменяет содержимое диалога (спец 12.12). func (p *ChatPanel) SetTranscript(text string) { p.convLbl.SetText(text) diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go index 6e74601..926f2cf 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -150,6 +150,8 @@ func (w *Window) addTab(sess *ui.Session) { panel.SetCommands(ui.NewCommands(func(text string) { w.submitText(sess.UserID, sess.Address, text) })) + // Кнопка «Перезапустить» активна только у вкладки с задачей (/retry N). + panel.SetBoundTask(sess.BoundTask) content := container.NewBorder(nil, panel.Composer(), nil, nil, panel.Transcript()) tab := container.NewTabItem(sess.Title, content) @@ -309,6 +311,9 @@ func (w *Window) bindTaskSession(chatID string, taskID int64) { continue } s.BoundTask = taskID + if panel := w.panels[addr]; panel != nil { + panel.SetBoundTask(taskID) + } if tab := w.tabForAddress(addr); tab != nil { tab.Text = "Задача #" + ui.Itoa(taskID) w.tabs.Refresh()