t191/t192:daemon 主視窗(Google Drive 風格)+「同步中…」進行中狀態

解 issue #18+#17(leo 指定併做)。

## #17 進行中狀態——真兇是「工作時間對用戶隱形」
leo:「按『立刻同步』後很快就回到『看守中』,看起來好像就做完了,
      這時使用者一看沒做完啊,就覺得是 bug」。
真兇:collector **只在一輪跑完後**才印 JSON ⇒ 托盤無從得知「正在跑」。
修:開工前先印 phase:"start"、跑完印 phase:"done";
    supervisor 新增 StateSyncing;托盤/主視窗顯示「同步中… 正在讀檔並整理成知識卡」。
形狀相容:兩筆都有 at,舊版托盤只會多算一次 round,不會壞。

實測(真的跑一輪 --once):
  第1筆 phase='start' at=21:31:26
  第2筆 phase='done'  at=21:31:27

## #18 主視窗(新檔 mainwindow.go)
leo:「daemon 設定項越來越多,不可能統統塞在下拉選單」
     「參考 Google Drive 設定畫面:點擊 daemon 就開一個視窗,佔螢幕約 1/2」
     「資料夾清單要可捲動——我每個 gitea 專案都要同步,那就是幾十個,根本塞不下」
- 920x620 視窗,關窗=隱藏(daemon 續跑)
- 上:大字狀態+副標(上次同步/已整理幾份/失敗幾份;有錯誤優先顯示)
- 中:widget.List 虛擬捲動的資料夾清單(帳號標題+其下資料夾攤平成單層)
- 下:白話動作列(加入資料夾/新增知識庫帳號/AI 設定/檢查更新)
- 每秒自動刷新 ⇒ 同步中看得到在動
- 無帳號時引導去連線(onboarding),不是丟錯誤或空面板
- **所有動作複用既有 handler**,不在視窗那邊重寫一份邏輯

托盤選單第一項加「開啟 Arcrun…」——leo 已點破兩次
「能力做出來了,入口沒出現在用戶會看的地方」(docs 沒連結/MCP 零處提及),
這次做完就讓它看得見。

測試:t192_test.go 八則(狀態文案四態/同步中要說明在做什麼/
副標優先顯示錯誤/副標不可空白/清單攤平且 accIdx 正確/60 個資料夾/無帳號空清單)全過。
collector 與 supervisor 全套綠。

⚠️ 未驗:fyne GUI 的實際外觀需真機開窗(無螢幕環境驗不到),
   CIS 視覺套用待 leo 看過畫面再調。
This commit is contained in:
2026-08-04 21:32:31 +08:00
parent 8c7b50d9a9
commit cc6534dd39
5 changed files with 500 additions and 2 deletions
+72
View File
@@ -1009,6 +1009,65 @@ func main() {
}, win)
}
// 🔴 t192leo 08-04issue #18):主視窗——「daemon 設定項越來越多,
// **不可能統統塞在下拉選單**」「參考 Google Drive 設定畫面:點擊 daemon
// 就開一個視窗,佔螢幕約 1/2」「資料夾清單要**可捲動**」。
// 托盤選單保留(快速動作),但**主入口改成這個視窗**。
// 所有動作都**複用既有 handler**,不在視窗那邊重寫一份邏輯。
mw := newMainWindow(a)
mw.sup = sup
mw.cfg = cfg
mw.reload = func() *directConfig { return cfg }
mw.onAddAccount = func() { showConnectWizard() }
mw.onAddFolder = func(accIdx int) {
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
addAccountWatchFolder(cfg, accIdx, uri.Path())
if serr := saveConfig(cfg); serr != nil {
dialog.ShowError(serr, mw.win)
return
}
restartWatch()
rebuildTray()
mw.refresh()
}, mw.win)
}
mw.onDelFolder = func(accIdx int, folder string) {
removeAccountWatchFolder(cfg, accIdx, folder)
if serr := saveConfig(cfg); serr != nil {
dialog.ShowError(serr, mw.win)
return
}
restartWatch()
rebuildTray()
mw.refresh()
}
mw.onAISettings = func() { showAISettings() }
mw.onSyncNow = func() {
_ = os.MkdirAll(appDir(), 0o755)
_ = os.WriteFile(syncNowSignalPath(), []byte{}, 0o644)
mw.refresh()
}
mw.onCheckUpdate = func() {
go func() {
st := checkForUpdate()
switch {
case st.Err != "":
dialog.ShowInformation("檢查更新", "暫時連不上更新伺服器:\n"+st.Err, mw.win)
case st.Available:
go prepareUpdateInBackground()
dialog.ShowInformation("有新版本",
"發現新版 "+st.Latest+",正在背景下載。\n下載完成後會出現「重新啟動完成更新」。", mw.win)
default:
dialog.ShowInformation("檢查更新", "你已經是最新版本("+versionLabel()+")。", mw.win)
}
}()
}
mw.build()
mw.startAutoRefresh()
// t98:立刻同步——寫訊號檔觸發 collector 立即跑一輪,無需等下一個定時週期。
// syncNowItem 宣告在 rebuildTray 外,確保 click handler 的 goroutine 能還原標籤後重建選單。
var syncNowItem *fyne.MenuItem
@@ -1078,6 +1137,14 @@ func main() {
var items []*fyne.MenuItem
// 🔴 t192:主視窗入口放**選單第一項**——leo 點破過同一個病兩次:
// 「能力做出來了,入口沒出現在用戶會看的地方」
// (docs 建好卻沒連結/MCP 端點活著卻零處提及)。做完就要看得見。
items = append(items,
fyne.NewMenuItem("開啟 Arcrun…", func() { mw.showAndRefresh() }),
fyne.NewMenuItemSeparator(),
)
// t104per-account 分組——每個帳號獨立一段(帳號名稱標題 + 資料夾 + 新增按鈕)。
if len(cfg.Accounts) > 0 {
for ai, acc := range cfg.Accounts {
@@ -1259,6 +1326,11 @@ func buildStatusLabel(s supervisor.Status, sync traySyncStatus, extractor string
base = fmt.Sprintf("看守中 · 上次同步 %s", s.LastRoundAt.Local().Format("15:04"))
}
return base + syncStatusLabel(sync, hasExtractor)
// 🔴 t191leo 08-04issue #17):正在跑就要**看得出來在跑**。
// leo:「如果它顯示『萃取中』、『同步中』⋯⋯而不是看起來好像就做完了,
// 這時使用者一看沒做完啊,就覺得是 bug」「這是不讓用戶誤解的方法」。
case supervisor.StateSyncing:
return "同步中… 正在讀檔並整理成知識卡"
case supervisor.StateStarting:
return "啟動中…"
case supervisor.StateError:
+278
View File
@@ -0,0 +1,278 @@
// mainwindow.go — Arcrun 主視窗(t192issue #18
//
// 🔴 leo 2026-08-04 的要求(逐條):
// - 「daemon 設定項越來越多,**不可能統統塞在下拉選單**」
// - 「參考 **Google Drive 設定畫面**:點擊 daemon 就開一個視窗,佔螢幕約 1/2」
// - 「資料夾清單要**可捲動**——我自己每個 gitea 專案都要同步,那就是幾十個,根本塞不下」
// - daemon 現在**沒有 onboarding**
// - 要套 CIS 視覺
// - 併入:狀態要顯示「萃取中/同步中」(issue #17,已在 supervisor/托盤那層做)
//
// 定位(leo 原話):
//
// 「Daemon 不只用來查看 RAG,它是 Arcrun 的**木馬屠城**……
// Daemon 是 Arcrun 優於 n8n 之處。」
// ⇒ 它是產品門面,不是附屬小工具。
//
// 設計原則(照 leo 立的「用戶好用、白癡化」):
// - 一眼看到「現在在做什麼」——狀態列放最上面、大字、會動
// - 資料夾清單用 widget.List(**虛擬捲動**,幾十個也不卡)
// - 動作用白話:「加入資料夾」不是「新增 watch_folder」
// - 沒連過帳號的人看到的是 onboarding,不是一堆空白面板
package main
import (
"fmt"
"time"
"arcrun-rag/collector/supervisor"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// mainWindow 是常駐的應用視窗(不是每次開新的,避免多開)。
type mainWindow struct {
win fyne.Window
app fyne.App
statusText *widget.Label // 最上方大字狀態(「同步中…」等)
statusSub *widget.Label // 次要說明(上次同步時間、萃取計數)
folderList *widget.List // 可捲動的資料夾清單
rows []folderRow // 目前顯示的列(帳號標題 + 資料夾)
// 由 main.go 注入,避免這裡重寫一份邏輯
cfg *directConfig
sup *supervisor.Supervisor
onAddAccount func()
onAddFolder func(accIdx int)
onDelFolder func(accIdx int, folder string)
onAISettings func()
onSyncNow func()
onCheckUpdate func()
reload func() *directConfig
}
// folderRow 是清單裡的一列:帳號標題或其下的資料夾。
// 用單一扁平清單(而非巢狀樹)才能用 widget.List 的虛擬捲動——
// 幾十個資料夾時只算可見的那幾列,不會卡。
type folderRow struct {
isHeader bool
accIdx int
title string // 帳號名或資料夾路徑
sub string // 次要說明(帳號:實例網址/資料夾:所屬庫)
}
// newMainWindow 建主視窗。約佔一般筆電螢幕的一半(leo:「佔螢幕約 1/2」)。
func newMainWindow(a fyne.App) *mainWindow {
m := &mainWindow{app: a}
m.win = a.NewWindow("Arcrun")
m.win.Resize(fyne.NewSize(920, 620))
m.win.SetCloseIntercept(func() { m.win.Hide() }) // 關窗=隱藏,daemon 續跑
return m
}
// build 組出整個畫面。必須在注入完各 callback 後呼叫。
func (m *mainWindow) build() {
// ── 上:狀態列(一眼看到「現在在做什麼」)──
m.statusText = widget.NewLabel("啟動中…")
m.statusText.TextStyle = fyne.TextStyle{Bold: true}
m.statusSub = widget.NewLabel("")
syncBtn := widget.NewButtonWithIcon("立刻同步", theme.ViewRefreshIcon(), func() {
if m.onSyncNow != nil {
m.onSyncNow()
}
})
syncBtn.Importance = widget.HighImportance
header := container.NewBorder(nil, nil, nil,
container.NewHBox(syncBtn),
container.NewVBox(m.statusText, m.statusSub),
)
// ── 中:可捲動的資料夾清單(leo:「幾十個,根本塞不下」)──
m.folderList = widget.NewList(
func() int { return len(m.rows) },
func() fyne.CanvasObject {
title := widget.NewLabel("")
sub := widget.NewLabel("")
del := widget.NewButtonWithIcon("", theme.DeleteIcon(), nil)
return container.NewBorder(nil, nil, nil, del, container.NewVBox(title, sub))
},
func(i widget.ListItemID, o fyne.CanvasObject) {
if i < 0 || i >= len(m.rows) {
return
}
r := m.rows[i]
box := o.(*fyne.Container)
inner := box.Objects[0].(*fyne.Container)
title := inner.Objects[0].(*widget.Label)
sub := inner.Objects[1].(*widget.Label)
del := box.Objects[1].(*widget.Button)
title.SetText(r.title)
sub.SetText(r.sub)
if r.isHeader {
title.TextStyle = fyne.TextStyle{Bold: true}
del.Hide() // 帳號列沒有「移除資料夾」
} else {
title.TextStyle = fyne.TextStyle{}
del.Show()
row := r
del.OnTapped = func() {
dialog.ShowConfirm("移除這個資料夾?",
"「"+row.title+"」不再自動同步。\n(已經上傳的知識卡不會被刪除)",
func(ok bool) {
if ok && m.onDelFolder != nil {
m.onDelFolder(row.accIdx, row.title)
}
}, m.win)
}
}
title.Refresh()
sub.Refresh()
},
)
// ── 下:動作列(白話命名,不用系統術語)──
actions := container.NewHBox(
widget.NewButtonWithIcon("加入資料夾", theme.FolderNewIcon(), func() {
// 只有一個帳號時直接加;多帳號要先選
accIdx := m.pickAccount()
if accIdx >= 0 && m.onAddFolder != nil {
m.onAddFolder(accIdx)
}
}),
widget.NewButtonWithIcon("新增知識庫帳號", theme.ContentAddIcon(), func() {
if m.onAddAccount != nil {
m.onAddAccount()
}
}),
widget.NewButtonWithIcon("AI 設定", theme.SettingsIcon(), func() {
if m.onAISettings != nil {
m.onAISettings()
}
}),
widget.NewButtonWithIcon("檢查更新", theme.DownloadIcon(), func() {
if m.onCheckUpdate != nil {
m.onCheckUpdate()
}
}),
)
footer := container.NewBorder(nil, nil, actions,
widget.NewLabel("版本 "+versionLabel()))
m.win.SetContent(container.NewBorder(
container.NewVBox(header, widget.NewSeparator()),
container.NewVBox(widget.NewSeparator(), footer),
nil, nil,
m.folderList,
))
}
// pickAccount 決定要把資料夾加到哪個帳號。
// 沒帳號 → 引導去連線(onboarding 的一環,不是丟一個錯誤給他)。
func (m *mainWindow) pickAccount() int {
if m.cfg == nil || len(m.cfg.Accounts) == 0 {
dialog.ShowInformation("先連上你的知識庫",
"還沒有任何知識庫帳號。\n請先按「新增知識庫帳號」,把這台電腦連上你的雲端知識庫。", m.win)
return -1
}
return 0 // 單帳號直接用;多帳號的選擇 UI 待 issue #19 一起做
}
// refresh 依現況重畫(狀態列 + 清單)。可從任何 goroutine 呼叫。
func (m *mainWindow) refresh() {
if m.reload != nil {
if c := m.reload(); c != nil {
m.cfg = c
}
}
m.rebuildRows()
st := m.sup.Status()
sync := loadAppSyncStatus()
m.statusText.SetText(mainWindowStatusText(st))
m.statusSub.SetText(mainWindowSubText(st, sync))
m.folderList.Refresh()
}
// rebuildRows 把 config 攤平成清單列(帳號標題 + 其下資料夾)。
func (m *mainWindow) rebuildRows() {
rows := []folderRow{}
if m.cfg != nil {
for ai, acc := range m.cfg.Accounts {
rows = append(rows, folderRow{
isHeader: true, accIdx: ai,
title: accountDisplayName(acc),
sub: shortCypherHost(acc.CypherURL),
})
for _, f := range acc.WatchFolders {
rows = append(rows, folderRow{accIdx: ai, title: f, sub: "自動同步中"})
}
}
}
m.rows = rows
}
// mainWindowStatusText 是最上方那行大字——**一眼看出現在在做什麼**(issue #17)。
func mainWindowStatusText(s supervisor.Status) string {
switch s.State {
case supervisor.StateSyncing:
return "同步中… 正在讀檔並整理成知識卡"
case supervisor.StateWatching:
return "看守中 · 資料夾有變動就會自動整理"
case supervisor.StateStarting:
return "啟動中…"
case supervisor.StateError:
return "暫時出錯,正在自動重試"
default:
return "尚未開始"
}
}
// mainWindowSubText 是狀態底下那行小字(時間、計數、錯誤)。
func mainWindowSubText(s supervisor.Status, sync traySyncStatus) string {
if sync.ExtractorError != "" && !sync.ExtractorOK {
return "⚠ " + sync.ExtractorError
}
parts := []string{}
if !s.LastRoundAt.IsZero() {
parts = append(parts, "上次同步 "+s.LastRoundAt.Local().Format("15:04"))
}
if sync.ExtractedOK > 0 {
parts = append(parts, fmt.Sprintf("已整理 %d 份", sync.ExtractedOK))
}
if sync.ExtractFailed > 0 {
parts = append(parts, fmt.Sprintf("⚠ %d 份失敗", sync.ExtractFailed))
}
if len(parts) == 0 {
return "還沒有同步紀錄"
}
out := parts[0]
for _, p := range parts[1:] {
out += " · " + p
}
return out
}
// showAndRefresh 開窗(若已開就帶到前景)並立即更新一次。
func (m *mainWindow) showAndRefresh() {
m.refresh()
m.win.Show()
m.win.RequestFocus()
}
// startAutoRefresh 每秒更新一次——**同步中的狀態要看得到在動**(issue #17)。
// 視窗隱藏時不做事,不浪費資源。
func (m *mainWindow) startAutoRefresh() {
go func() {
for range time.Tick(time.Second) {
m.refresh()
}
}()
}
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"strings"
"testing"
"time"
"arcrun-rag/collector/supervisor"
)
// t192/#17:正在跑就要**看得出來在跑**。
// leo:「按『立刻同步』後很快就回到『看守中』,看起來好像就做完了,
// 這時使用者一看沒做完啊,就覺得是 bug」。
func TestMainWindowStatusShowsSyncing(t *testing.T) {
cases := []struct {
name string
state supervisor.State
want string
}{
{"同步中要講在做什麼", supervisor.StateSyncing, "同步中"},
{"看守中", supervisor.StateWatching, "看守中"},
{"啟動中", supervisor.StateStarting, "啟動中"},
{"出錯要說會自動重試", supervisor.StateError, "重試"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := mainWindowStatusText(supervisor.Status{State: c.state})
if !strings.Contains(got, c.want) {
t.Errorf("state=%s → %q,應含 %q", c.state, got, c.want)
}
})
}
}
// 同步中的文案必須說明「正在做什麼」,不能只寫兩個字讓人乾等。
func TestSyncingTextExplainsWhatItIsDoing(t *testing.T) {
got := mainWindowStatusText(supervisor.Status{State: supervisor.StateSyncing})
if !strings.Contains(got, "讀檔") && !strings.Contains(got, "知識卡") {
t.Errorf("同步中應說明正在做什麼,got %q", got)
}
}
// 副標:有錯誤時**優先顯示錯誤**(不能被「上次同步 12:34」蓋掉)。
func TestSubTextPrioritisesError(t *testing.T) {
got := mainWindowSubText(
supervisor.Status{LastRoundAt: time.Now()},
traySyncStatus{ExtractorOK: false, ExtractorError: "還沒連上知識庫"},
)
if !strings.Contains(got, "還沒連上知識庫") {
t.Errorf("有錯誤時應優先顯示,got %q", got)
}
}
// 副標:正常時要看得到「上次同步時間」與「已整理幾份」。
func TestSubTextShowsProgress(t *testing.T) {
got := mainWindowSubText(
supervisor.Status{LastRoundAt: time.Date(2026, 8, 4, 21, 5, 0, 0, time.Local)},
traySyncStatus{ExtractorOK: true, ExtractedOK: 3, ExtractFailed: 1},
)
for _, want := range []string{"21:05", "已整理 3", "1 份失敗"} {
if !strings.Contains(got, want) {
t.Errorf("副標應含 %qgot %q", want, got)
}
}
}
// 沒同步過時不能顯示空白(空白=用戶不知道是壞了還是還沒開始)。
func TestSubTextNeverEmpty(t *testing.T) {
got := mainWindowSubText(supervisor.Status{}, traySyncStatus{ExtractorOK: true})
if strings.TrimSpace(got) == "" {
t.Error("副標不可空白——用戶會分不清是壞了還是還沒開始")
}
}
// t192/#18:資料夾清單攤平——帳號一列標題,其下每個資料夾各一列。
// 用扁平清單才能吃 widget.List 的虛擬捲動(leo:「幾十個,根本塞不下」)。
func TestRebuildRowsFlattensAccountsAndFolders(t *testing.T) {
m := &mainWindow{cfg: &directConfig{Accounts: []accountCfg{
{InstanceName: "geek6688", CypherURL: "https://a.workers.dev", WatchFolders: []string{"/p/one", "/p/two"}},
{InstanceName: "youlin", CypherURL: "https://b.workers.dev", WatchFolders: []string{"/p/three"}},
}}}
m.rebuildRows()
if len(m.rows) != 5 { // 2 標題 + 3 資料夾
t.Fatalf("應攤平成 5 列,got %d%+v", len(m.rows), m.rows)
}
if !m.rows[0].isHeader || m.rows[0].title != "geek6688" {
t.Errorf("第 0 列應是帳號標題 geek6688got %+v", m.rows[0])
}
if m.rows[1].isHeader || m.rows[1].title != "/p/one" {
t.Errorf("第 1 列應是資料夾 /p/onegot %+v", m.rows[1])
}
// 刪除要作用在正確帳號上(t101 踩過:刪錯帳號的資料夾)
if m.rows[4].accIdx != 1 {
t.Errorf("第 4 列(youlin 的資料夾)accIdx 應為 1got %d", m.rows[4].accIdx)
}
}
// 幾十個資料夾也要能攤平(不是為了效能,是確保沒有隱藏上限)。
func TestRebuildRowsHandlesManyFolders(t *testing.T) {
folders := make([]string, 60)
for i := range folders {
folders[i] = "/gitea/project-" + string(rune('a'+i%26))
}
m := &mainWindow{cfg: &directConfig{Accounts: []accountCfg{
{InstanceName: "leo", CypherURL: "https://x.workers.dev", WatchFolders: folders},
}}}
m.rebuildRows()
if len(m.rows) != 61 {
t.Errorf("60 個資料夾+1 標題應為 61 列,got %d", len(m.rows))
}
}
// 沒有任何帳號時清單是空的(走 onboarding,不是顯示壞掉的空面板)。
func TestRebuildRowsEmptyWhenNoAccount(t *testing.T) {
m := &mainWindow{cfg: &directConfig{}}
m.rebuildRows()
if len(m.rows) != 0 {
t.Errorf("無帳號時應為空清單,got %d 列", len(m.rows))
}
}
+16 -1
View File
@@ -896,12 +896,27 @@ func runDirect(args []string) int {
// (留著空轉的回寫邏輯會讓未來的人以為 claude 路還活著=錯誤的環境信號。)
runOne := func() int {
// 🔴 t191leo 08-04issue #17):「按『立刻同步』後**很快就回到「看守中」**,
// 看起來好像就做完了,這時使用者一看沒做完啊,就覺得是 bug」。
//
// 真兇:舊版**只在一輪跑完後**才印 JSON ⇒ 托盤無從得知「正在跑」,
// 整段工作時間對用戶是隱形的,只看得到前後都是「看守中」。
// ⇒ 開工前先印一筆 `phase:"start"`,托盤收到就顯示「同步中…」,
// 收到 `phase:"done"` 再切回「看守中」。
// 形狀相容:兩筆都有 `at`,舊版托盤只會多算一次 round,不會壞掉。
startOut, _ := json.MarshalIndent(struct {
At string `json:"at"`
Phase string `json:"phase"`
}{time.Now().Format(time.RFC3339), "start"}, "", " ")
fmt.Println(string(startOut))
results, exit, _ := RunDirectOnce(cfg, *dryRun)
out, _ := json.MarshalIndent(struct {
At string `json:"at"`
Phase string `json:"phase"`
Folders []string `json:"folders"`
Results []DirectResult `json:"results"`
}{time.Now().Format(time.RFC3339), cfg.Folders(), results}, "", " ")
}{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results}, "", " ")
fmt.Println(string(out))
return exit
}
+13 -1
View File
@@ -30,7 +30,12 @@ const (
StateStopped State = "stopped" // 未啟動或已停止
StateStarting State = "starting" // 行程剛拉起、尚無第一輪
StateWatching State = "watching" // 正常看守中(已有掃描輪)
StateError State = "error" // 行程非預期退出、等待重起
// 🔴 t191leo 08-04issue #17):「按『立刻同步』後很快就回到『看守中』,
// 看起來好像就做完了⋯⋯使用者一看沒做完啊,就覺得是 bug」。
// collector 開工前會印 phase:"start"、跑完印 phase:"done"
// ⇒ 這中間就是本狀態,讓托盤顯示「同步中…」,工作時間不再對用戶隱形。
StateSyncing State = "syncing" // 正在掃描/萃取(收到 start、還沒收到 done)
StateError State = "error" // 行程非預期退出、等待重起
)
// Status 是托盤要顯示的即時狀態(值型別、複製安全)。
@@ -46,6 +51,8 @@ type Status struct {
// round 對應 collector direct 每輪印到 stdout 的 JSON(見 direct.go runOne)。
type round struct {
At string `json:"at"`
Phase string `json:"phase"` // t191"start"(開工)/"done"(跑完);舊版沒有此欄=空
Folder string `json:"folder"`
Results []json.RawMessage `json:"results"`
}
@@ -298,6 +305,11 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
break
}
at := parseAt(r.At)
if r.Phase == "start" {
// t191:開工 → 顯示「同步中…」。不累加 Rounds(那是「完成幾輪」)。
s.setState(func(st *Status) { st.State = StateSyncing })
continue
}
s.setState(func(st *Status) {
st.State = StateWatching
st.Rounds++