From 024cd0e4aaf7f3d27d2f4458c35af2d17db1372d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 16:03:12 +0100 Subject: [PATCH] feat: sync workboard cards with sessions --- docs/plugins/workboard.md | 26 ++- ui/src/i18n/locales/ar.ts | 14 ++ ui/src/i18n/locales/de.ts | 14 ++ ui/src/i18n/locales/en.ts | 14 ++ ui/src/i18n/locales/es.ts | 14 ++ ui/src/i18n/locales/fa.ts | 14 ++ ui/src/i18n/locales/fr.ts | 14 ++ ui/src/i18n/locales/id.ts | 14 ++ ui/src/i18n/locales/it.ts | 14 ++ ui/src/i18n/locales/ja-JP.ts | 14 ++ ui/src/i18n/locales/ko.ts | 14 ++ ui/src/i18n/locales/nl.ts | 14 ++ ui/src/i18n/locales/pl.ts | 14 ++ ui/src/i18n/locales/pt-BR.ts | 14 ++ ui/src/i18n/locales/th.ts | 14 ++ ui/src/i18n/locales/tr.ts | 14 ++ ui/src/i18n/locales/uk.ts | 14 ++ ui/src/i18n/locales/vi.ts | 14 ++ ui/src/i18n/locales/zh-CN.ts | 14 ++ ui/src/i18n/locales/zh-TW.ts | 14 ++ ui/src/styles/workboard.css | 32 +++ ...p-settings.refresh-active-tab.node.test.ts | 4 +- ui/src/ui/app-settings.ts | 2 +- ui/src/ui/controllers/workboard.test.ts | 199 +++++++++++++++++- ui/src/ui/controllers/workboard.ts | 160 ++++++++++++++ ui/src/ui/views/workboard.test.ts | 45 +++- ui/src/ui/views/workboard.ts | 113 +++++++++- 27 files changed, 837 insertions(+), 10 deletions(-) diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md index 9f5c2b5720d..e49b957e341 100644 --- a/docs/plugins/workboard.md +++ b/docs/plugins/workboard.md @@ -51,19 +51,41 @@ Each card stores: Cards are stored in the plugin's Gateway state. They are local to the Gateway state directory and move with the rest of that Gateway's OpenClaw state. +## Session lifecycle sync + +Cards can be linked to existing dashboard sessions or to the session created +when you start work from a card. Linked cards show the session lifecycle inline: +running, linked idle, done, failed, or missing. + +Workboard follows the linked session while the card is still in an active work +state: + +- active linked session -> `running` +- completed linked session -> `review` +- failed, killed, timed out, or aborted linked session -> `blocked` + +Manual review states win. If you move a card to `review`, `blocked`, or `done`, +Workboard stops auto-moving that card until you move it back to `todo` or +`running`. + ## Dashboard workflow 1. Open the Workboard tab in the Control UI. -2. Create a card with a title, notes, priority, labels, and optional agent. +2. Create a card with a title, notes, priority, labels, optional agent, and + optional linked session. 3. Drag the card between columns or use the column controls. 4. Start work from the card to create or reuse a dashboard session. 5. Open the linked session from the card while the agent works. -6. Move the card to review, blocked, or done as the work changes state. +6. Let lifecycle sync move running work into review or blocked, then manually + move the card to done when accepted. Starting a card uses normal Gateway sessions. The Workboard plugin only stores card metadata and links; the conversation transcript, model selection, and run lifecycle stay owned by the regular session system. +Use Stop on a live linked card to abort the active session run. Workboard marks +that card `blocked` so it remains visible for follow-up. + ## Permissions The plugin registers Gateway RPC methods under the `workboard.*` namespace: diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 0a0a2914ee8..7e50e687c60 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -478,6 +478,20 @@ export const ar: TranslationMap = { disabledHelpStart: "لوحة العمل معطّلة. فعّل", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: "، ثم أعد تحميل علامة التبويب هذه.", + noLinkedSession: "لا توجد جلسة مرتبطة", + stopSession: "إيقاف الجلسة", + lifecycleUnlinked: "لا توجد جلسة", + lifecycleUnlinkedDetail: "ابدأ جلسة أو اربطها", + lifecycleMissing: "الجلسة مفقودة", + lifecycleMissingDetail: "أعد تحميل الجلسات أو أعد ربط هذه البطاقة", + lifecycleLinked: "مرتبط", + lifecycleIdleDetail: "لا يوجد تشغيل نشط", + lifecycleRunning: "قيد التشغيل", + lifecycleRunningDetail: "تشغيل نشط قيد التقدم", + lifecycleDone: "مكتمل", + lifecycleDoneDetail: "تم النقل إلى المراجعة", + lifecycleNeedsReview: "تحتاج إلى مراجعة", + lifecycleNeedsReviewDetail: "توقف التشغيل أو فشل", }, overview: { access: { diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 5f06bdc7977..5c7d92fdbc4 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -482,6 +482,20 @@ export const de: TranslationMap = { disabledHelpStart: "Workboard ist deaktiviert. Aktivieren Sie", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", und laden Sie dann diesen Tab neu.", + noLinkedSession: "Keine verknüpfte Sitzung", + stopSession: "Sitzung stoppen", + lifecycleUnlinked: "Keine Sitzung", + lifecycleUnlinkedDetail: "Sitzung starten oder verknüpfen", + lifecycleMissing: "Sitzung fehlt", + lifecycleMissingDetail: "Sitzungen neu laden oder diese Karte erneut verknüpfen", + lifecycleLinked: "Verknüpft", + lifecycleIdleDetail: "Kein aktiver Lauf", + lifecycleRunning: "Wird ausgeführt", + lifecycleRunningDetail: "Aktiver Lauf läuft", + lifecycleDone: "Fertig", + lifecycleDoneDetail: "Zur Überprüfung verschoben", + lifecycleNeedsReview: "Überprüfung erforderlich", + lifecycleNeedsReviewDetail: "Lauf gestoppt oder fehlgeschlagen", }, overview: { access: { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 837b2743a0c..cc124c96add 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -477,6 +477,20 @@ export const en: TranslationMap = { disabledHelpStart: "Workboard is disabled. Enable", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", then reload this tab.", + noLinkedSession: "No linked session", + stopSession: "Stop session", + lifecycleUnlinked: "No session", + lifecycleUnlinkedDetail: "Start or link a session", + lifecycleMissing: "Session missing", + lifecycleMissingDetail: "Reload sessions or relink this card", + lifecycleLinked: "Linked", + lifecycleIdleDetail: "No active run", + lifecycleRunning: "Running", + lifecycleRunningDetail: "Active run in progress", + lifecycleDone: "Done", + lifecycleDoneDetail: "Moved to review", + lifecycleNeedsReview: "Needs review", + lifecycleNeedsReviewDetail: "Run stopped or failed", }, overview: { access: { diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 3b2e3de8a6d..952e657398b 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -479,6 +479,20 @@ export const es: TranslationMap = { disabledHelpStart: "Workboard está desactivado. Activa", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", luego vuelve a cargar esta pestaña.", + noLinkedSession: "Sin sesión vinculada", + stopSession: "Detener sesión", + lifecycleUnlinked: "Sin sesión", + lifecycleUnlinkedDetail: "Inicia o vincula una sesión", + lifecycleMissing: "Falta la sesión", + lifecycleMissingDetail: "Recarga las sesiones o vuelve a vincular esta tarjeta", + lifecycleLinked: "Vinculado", + lifecycleIdleDetail: "No hay ninguna ejecución activa", + lifecycleRunning: "En ejecución", + lifecycleRunningDetail: "Ejecución activa en curso", + lifecycleDone: "Completado", + lifecycleDoneDetail: "Movido a revisión", + lifecycleNeedsReview: "Necesita revisión", + lifecycleNeedsReviewDetail: "La ejecución se detuvo o falló", }, overview: { access: { diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index fd29bf88c4b..1213a20859a 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -480,6 +480,20 @@ export const fa: TranslationMap = { disabledHelpStart: "Workboard غیرفعال است. فعال کنید", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: "، سپس این زبانه را دوباره بارگذاری کنید.", + noLinkedSession: "جلسه‌ای پیوند نشده است", + stopSession: "توقف جلسه", + lifecycleUnlinked: "بدون جلسه", + lifecycleUnlinkedDetail: "یک جلسه را شروع یا پیوند کنید", + lifecycleMissing: "جلسه پیدا نشد", + lifecycleMissingDetail: "جلسه‌ها را دوباره بارگیری کنید یا این کارت را دوباره پیوند دهید", + lifecycleLinked: "پیوندشده", + lifecycleIdleDetail: "اجرای فعالی وجود ندارد", + lifecycleRunning: "در حال اجرا", + lifecycleRunningDetail: "اجرای فعال در جریان است", + lifecycleDone: "انجام شد", + lifecycleDoneDetail: "به بازبینی منتقل شد", + lifecycleNeedsReview: "نیازمند بازبینی", + lifecycleNeedsReviewDetail: "اجرا متوقف شد یا ناموفق بود", }, overview: { access: { diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 68e14339651..1fced3641de 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -481,6 +481,20 @@ export const fr: TranslationMap = { disabledHelpStart: "Le tableau de travail est désactivé. Activez", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", puis rechargez cet onglet.", + noLinkedSession: "Aucune session liée", + stopSession: "Arrêter la session", + lifecycleUnlinked: "Aucune session", + lifecycleUnlinkedDetail: "Démarrer ou lier une session", + lifecycleMissing: "Session manquante", + lifecycleMissingDetail: "Recharger les sessions ou relier cette carte", + lifecycleLinked: "Lié", + lifecycleIdleDetail: "Aucune exécution active", + lifecycleRunning: "En cours d’exécution", + lifecycleRunningDetail: "Exécution active en cours", + lifecycleDone: "Terminé", + lifecycleDoneDetail: "Déplacé vers la révision", + lifecycleNeedsReview: "Nécessite une révision", + lifecycleNeedsReviewDetail: "Exécution arrêtée ou échouée", }, overview: { access: { diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 74ce9bd6116..0b42947da37 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -479,6 +479,20 @@ export const id: TranslationMap = { disabledHelpStart: "Workboard dinonaktifkan. Aktifkan", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", lalu muat ulang tab ini.", + noLinkedSession: "Tidak ada sesi tertaut", + stopSession: "Hentikan sesi", + lifecycleUnlinked: "Tidak ada sesi", + lifecycleUnlinkedDetail: "Mulai atau tautkan sesi", + lifecycleMissing: "Sesi hilang", + lifecycleMissingDetail: "Muat ulang sesi atau tautkan ulang kartu ini", + lifecycleLinked: "Ditautkan", + lifecycleIdleDetail: "Tidak ada run aktif", + lifecycleRunning: "Berjalan", + lifecycleRunningDetail: "Run aktif sedang berlangsung", + lifecycleDone: "Selesai", + lifecycleDoneDetail: "Dipindahkan ke peninjauan", + lifecycleNeedsReview: "Perlu ditinjau", + lifecycleNeedsReviewDetail: "Run dihentikan atau gagal", }, overview: { access: { diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index 8aa24b18f2a..8f30bb50e28 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -481,6 +481,20 @@ export const it: TranslationMap = { disabledHelpStart: "Workboard è disabilitata. Abilita", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", quindi ricarica questa scheda.", + noLinkedSession: "Nessuna sessione collegata", + stopSession: "Interrompi sessione", + lifecycleUnlinked: "Nessuna sessione", + lifecycleUnlinkedDetail: "Avvia o collega una sessione", + lifecycleMissing: "Sessione mancante", + lifecycleMissingDetail: "Ricarica le sessioni o ricollega questa scheda", + lifecycleLinked: "Collegato", + lifecycleIdleDetail: "Nessuna esecuzione attiva", + lifecycleRunning: "In esecuzione", + lifecycleRunningDetail: "Esecuzione attiva in corso", + lifecycleDone: "Completato", + lifecycleDoneDetail: "Spostata in revisione", + lifecycleNeedsReview: "Richiede revisione", + lifecycleNeedsReviewDetail: "Esecuzione interrotta o non riuscita", }, overview: { access: { diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index ca6282fbf84..d9c1ea54f95 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -482,6 +482,20 @@ export const ja_JP: TranslationMap = { disabledHelpStart: "Workboard は無効になっています。有効にするには", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: "を設定してから、このタブを再読み込みしてください。", + noLinkedSession: "リンクされたセッションがありません", + stopSession: "セッションを停止", + lifecycleUnlinked: "セッションなし", + lifecycleUnlinkedDetail: "セッションを開始またはリンク", + lifecycleMissing: "セッションが見つかりません", + lifecycleMissingDetail: "セッションを再読み込みするか、このカードを再リンク", + lifecycleLinked: "リンク済み", + lifecycleIdleDetail: "アクティブな実行はありません", + lifecycleRunning: "実行中", + lifecycleRunningDetail: "アクティブな実行が進行中です", + lifecycleDone: "完了", + lifecycleDoneDetail: "レビューに移動しました", + lifecycleNeedsReview: "レビューが必要", + lifecycleNeedsReviewDetail: "実行が停止または失敗しました", }, overview: { access: { diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 73c89cc631a..885a1d38116 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -478,6 +478,20 @@ export const ko: TranslationMap = { disabledHelpStart: "Workboard가 비활성화되어 있습니다. 활성화하려면", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", 그런 다음 이 탭을 새로고침하세요.", + noLinkedSession: "연결된 세션 없음", + stopSession: "세션 중지", + lifecycleUnlinked: "세션 없음", + lifecycleUnlinkedDetail: "세션을 시작하거나 연결하세요", + lifecycleMissing: "세션 누락", + lifecycleMissingDetail: "세션을 다시 로드하거나 이 카드를 다시 연결하세요", + lifecycleLinked: "연결됨", + lifecycleIdleDetail: "활성 실행 없음", + lifecycleRunning: "실행 중", + lifecycleRunningDetail: "활성 실행이 진행 중입니다", + lifecycleDone: "완료", + lifecycleDoneDetail: "검토로 이동됨", + lifecycleNeedsReview: "검토 필요", + lifecycleNeedsReviewDetail: "실행이 중지되었거나 실패했습니다", }, overview: { access: { diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index e06e5b4ec53..4a769675d73 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -481,6 +481,20 @@ export const nl: TranslationMap = { disabledHelpStart: "Workboard is uitgeschakeld. Schakel", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: "in en laad dit tabblad opnieuw.", + noLinkedSession: "Geen gekoppelde sessie", + stopSession: "Sessie stoppen", + lifecycleUnlinked: "Geen sessie", + lifecycleUnlinkedDetail: "Start of koppel een sessie", + lifecycleMissing: "Sessie ontbreekt", + lifecycleMissingDetail: "Laad sessies opnieuw of koppel deze kaart opnieuw", + lifecycleLinked: "Gekoppeld", + lifecycleIdleDetail: "Geen actieve run", + lifecycleRunning: "Actief", + lifecycleRunningDetail: "Actieve run bezig", + lifecycleDone: "Voltooid", + lifecycleDoneDetail: "Verplaatst naar review", + lifecycleNeedsReview: "Review nodig", + lifecycleNeedsReviewDetail: "Run gestopt of mislukt", }, overview: { access: { diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 7a77710e53a..706a07a0094 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -480,6 +480,20 @@ export const pl: TranslationMap = { disabledHelpStart: "Workboard jest wyłączony. Włącz", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", a następnie odśwież tę kartę.", + noLinkedSession: "Brak połączonej sesji", + stopSession: "Zatrzymaj sesję", + lifecycleUnlinked: "Brak sesji", + lifecycleUnlinkedDetail: "Rozpocznij lub połącz sesję", + lifecycleMissing: "Brak sesji", + lifecycleMissingDetail: "Załaduj sesje ponownie lub połącz tę kartę ponownie", + lifecycleLinked: "Połączono", + lifecycleIdleDetail: "Brak aktywnego uruchomienia", + lifecycleRunning: "Uruchomiono", + lifecycleRunningDetail: "Trwa aktywne uruchomienie", + lifecycleDone: "Gotowe", + lifecycleDoneDetail: "Przeniesiono do przeglądu", + lifecycleNeedsReview: "Wymaga przeglądu", + lifecycleNeedsReviewDetail: "Uruchomienie zatrzymane lub nieudane", }, overview: { access: { diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 1727a0a5cda..77d35ab68ea 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -479,6 +479,20 @@ export const pt_BR: TranslationMap = { disabledHelpStart: "O Workboard está desativado. Ative", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", depois recarregue esta aba.", + noLinkedSession: "Nenhuma sessão vinculada", + stopSession: "Parar sessão", + lifecycleUnlinked: "Nenhuma sessão", + lifecycleUnlinkedDetail: "Inicie ou vincule uma sessão", + lifecycleMissing: "Sessão ausente", + lifecycleMissingDetail: "Recarregue as sessões ou vincule este cartão novamente", + lifecycleLinked: "Vinculado", + lifecycleIdleDetail: "Nenhuma execução ativa", + lifecycleRunning: "Em execução", + lifecycleRunningDetail: "Execução ativa em andamento", + lifecycleDone: "Concluída", + lifecycleDoneDetail: "Movido para revisão", + lifecycleNeedsReview: "Precisa de revisão", + lifecycleNeedsReviewDetail: "A execução foi interrompida ou falhou", }, overview: { access: { diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index bfeaed9780f..f615b32adb9 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -477,6 +477,20 @@ export const th: TranslationMap = { disabledHelpStart: "Workboard ถูกปิดใช้งาน เปิดใช้งาน", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: " แล้วโหลดแท็บนี้ใหม่", + noLinkedSession: "ไม่มีเซสชันที่เชื่อมโยง", + stopSession: "หยุดเซสชัน", + lifecycleUnlinked: "ไม่มีเซสชัน", + lifecycleUnlinkedDetail: "เริ่มหรือเชื่อมโยงเซสชัน", + lifecycleMissing: "ไม่พบเซสชัน", + lifecycleMissingDetail: "โหลดเซสชันใหม่หรือเชื่อมโยงการ์ดนี้อีกครั้ง", + lifecycleLinked: "เชื่อมโยงแล้ว", + lifecycleIdleDetail: "ไม่มีการรันที่ทำงานอยู่", + lifecycleRunning: "กำลังทำงาน", + lifecycleRunningDetail: "กำลังมีการรันที่ทำงานอยู่", + lifecycleDone: "เสร็จสิ้น", + lifecycleDoneDetail: "ย้ายไปยังการตรวจทานแล้ว", + lifecycleNeedsReview: "ต้องตรวจทาน", + lifecycleNeedsReviewDetail: "การรันหยุดหรือไม่สำเร็จ", }, overview: { access: { diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index dac258ca26e..ebf3d3e19e2 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -481,6 +481,20 @@ export const tr: TranslationMap = { disabledHelpStart: "Workboard devre dışı. Etkinleştirin", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", ardından bu sekmeyi yeniden yükleyin.", + noLinkedSession: "Bağlı oturum yok", + stopSession: "Oturumu durdur", + lifecycleUnlinked: "Oturum yok", + lifecycleUnlinkedDetail: "Bir oturum başlatın veya bağlayın", + lifecycleMissing: "Oturum eksik", + lifecycleMissingDetail: "Oturumları yeniden yükleyin veya bu kartı yeniden bağlayın", + lifecycleLinked: "Bağlandı", + lifecycleIdleDetail: "Etkin çalışma yok", + lifecycleRunning: "Çalışıyor", + lifecycleRunningDetail: "Etkin çalışma devam ediyor", + lifecycleDone: "Tamamlandı", + lifecycleDoneDetail: "İncelemeye taşındı", + lifecycleNeedsReview: "İnceleme gerekli", + lifecycleNeedsReviewDetail: "Çalışma durduruldu veya başarısız oldu", }, overview: { access: { diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index e56e6b5989f..c2afe29fcba 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -480,6 +480,20 @@ export const uk: TranslationMap = { disabledHelpStart: "Workboard вимкнено. Увімкніть", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", потім перезавантажте цю вкладку.", + noLinkedSession: "Немає пов’язаної сесії", + stopSession: "Зупинити сесію", + lifecycleUnlinked: "Немає сесії", + lifecycleUnlinkedDetail: "Запустіть або пов’яжіть сесію", + lifecycleMissing: "Сесію не знайдено", + lifecycleMissingDetail: "Перезавантажте сесії або повторно пов’яжіть цю картку", + lifecycleLinked: "Пов’язано", + lifecycleIdleDetail: "Немає активного запуску", + lifecycleRunning: "Запущено", + lifecycleRunningDetail: "Виконується активний запуск", + lifecycleDone: "Готово", + lifecycleDoneDetail: "Переміщено на перевірку", + lifecycleNeedsReview: "Потребує перевірки", + lifecycleNeedsReviewDetail: "Запуск зупинено або він завершився помилкою", }, overview: { access: { diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 0fd33df2948..8d6b7299c52 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -479,6 +479,20 @@ export const vi: TranslationMap = { disabledHelpStart: "Workboard đã bị tắt. Bật", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ", rồi tải lại tab này.", + noLinkedSession: "Không có phiên được liên kết", + stopSession: "Dừng phiên", + lifecycleUnlinked: "Không có phiên", + lifecycleUnlinkedDetail: "Bắt đầu hoặc liên kết một phiên", + lifecycleMissing: "Thiếu phiên", + lifecycleMissingDetail: "Tải lại phiên hoặc liên kết lại thẻ này", + lifecycleLinked: "Đã liên kết", + lifecycleIdleDetail: "Không có lượt chạy đang hoạt động", + lifecycleRunning: "Đang chạy", + lifecycleRunningDetail: "Lượt chạy đang hoạt động đang diễn ra", + lifecycleDone: "Hoàn tất", + lifecycleDoneDetail: "Đã chuyển sang xem xét", + lifecycleNeedsReview: "Cần xem xét", + lifecycleNeedsReviewDetail: "Lượt chạy đã dừng hoặc thất bại", }, overview: { access: { diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 0101329905c..fdf0acc9879 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -476,6 +476,20 @@ export const zh_CN: TranslationMap = { disabledHelpStart: "Workboard 已禁用。启用", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ",然后重新加载此标签页。", + noLinkedSession: "没有已关联的会话", + stopSession: "停止会话", + lifecycleUnlinked: "无会话", + lifecycleUnlinkedDetail: "启动或关联会话", + lifecycleMissing: "会话缺失", + lifecycleMissingDetail: "重新加载会话或重新关联此卡片", + lifecycleLinked: "已关联", + lifecycleIdleDetail: "无活动运行", + lifecycleRunning: "运行中", + lifecycleRunningDetail: "活动运行正在进行", + lifecycleDone: "已完成", + lifecycleDoneDetail: "已移至审核", + lifecycleNeedsReview: "需要审核", + lifecycleNeedsReviewDetail: "运行已停止或失败", }, overview: { access: { diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index bbc3815d7eb..fa4fb64d690 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -476,6 +476,20 @@ export const zh_TW: TranslationMap = { disabledHelpStart: "Workboard 已停用。啟用", enableConfigKey: "plugins.entries.workboard.enabled = true", disabledHelpEnd: ",然後重新載入此分頁。", + noLinkedSession: "沒有已連結的工作階段", + stopSession: "停止工作階段", + lifecycleUnlinked: "沒有工作階段", + lifecycleUnlinkedDetail: "開始或連結工作階段", + lifecycleMissing: "找不到工作階段", + lifecycleMissingDetail: "重新載入工作階段或重新連結此卡片", + lifecycleLinked: "已連結", + lifecycleIdleDetail: "沒有進行中的執行", + lifecycleRunning: "執行中", + lifecycleRunningDetail: "正在執行中", + lifecycleDone: "完成", + lifecycleDoneDetail: "已移至審查", + lifecycleNeedsReview: "需要審查", + lifecycleNeedsReviewDetail: "執行已停止或失敗", }, overview: { access: { diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index 55095f7e4cf..a1a2f66ccf4 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -154,8 +154,25 @@ justify-content: flex-end; } +.workboard-card__lifecycle { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.workboard-card__lifecycle-detail { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 0.76rem; +} + .workboard-card__priority, .workboard-live, +.workboard-lifecycle, .workboard-labels span { border-radius: 999px; padding: 2px 7px; @@ -180,6 +197,21 @@ color: var(--accent); } +.workboard-lifecycle--live { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); +} + +.workboard-lifecycle--done { + background: color-mix(in srgb, var(--ok) 16%, transparent); + color: var(--ok); +} + +.workboard-lifecycle--blocked { + background: color-mix(in srgb, var(--danger) 16%, transparent); + color: var(--danger); +} + .workboard-empty { border: 1px dashed color-mix(in srgb, var(--border) 80%, transparent); border-radius: 8px; diff --git a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts index 917457915e9..1db81a5fe72 100644 --- a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts +++ b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts @@ -389,13 +389,15 @@ describe("refreshActiveTab", () => { }); }); - it("loads config before rendering the Workboard tab", async () => { + it("loads config, sessions, and agents before rendering the Workboard tab", async () => { const host = createHost(); host.tab = "workboard"; await refreshActiveTab(host as never); expect(mocks.loadConfigMock).toHaveBeenCalledOnce(); + expect(mocks.loadSessionsMock).toHaveBeenCalledOnce(); + expect(mocks.loadAgentsMock).toHaveBeenCalledOnce(); expect(mocks.loadConfigSchemaMock).not.toHaveBeenCalled(); }); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index fce80950b88..6febec9ed07 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -427,7 +427,7 @@ export async function refreshActiveTab(host: SettingsHost) { case "activity": break; case "workboard": - await loadConfig(app); + await Promise.all([loadConfig(app), loadSessions(app), loadAgents(app)]); break; case "channels": await loadChannelsTab(host); diff --git a/ui/src/ui/controllers/workboard.test.ts b/ui/src/ui/controllers/workboard.test.ts index d5dd96a5c37..1588f0b0618 100644 --- a/ui/src/ui/controllers/workboard.test.ts +++ b/ui/src/ui/controllers/workboard.test.ts @@ -1,15 +1,21 @@ import { describe, expect, it, vi } from "vitest"; +import type { GatewaySessionRow } from "../types.ts"; import { createWorkboardCard, + getWorkboardLifecycle, getWorkboardState, loadWorkboard, moveWorkboardCard, startWorkboardCard, + stopWorkboardCard, + syncWorkboardLifecycle, type WorkboardCard, } from "./workboard.ts"; -function createClient(responses: Record) { - const request = vi.fn(async (method: string) => responses[method]); +function createClient(responses: Record | ((method: string) => unknown)) { + const request = vi.fn(async (method: string) => + typeof responses === "function" ? responses(method) : responses[method], + ); return { request }; } @@ -24,6 +30,15 @@ const sampleCard: WorkboardCard = { updatedAt: 1, }; +const sampleSession: GatewaySessionRow = { + key: "agent:main:dashboard:1", + kind: "direct", + updatedAt: 2, + displayName: "Dashboard session", + hasActiveRun: true, + status: "running", +}; + describe("workboard controller", () => { it("loads cards through the plugin gateway method", async () => { const host = {}; @@ -42,7 +57,13 @@ describe("workboard controller", () => { const state = getWorkboardState(host); state.draftTitle = "Write tests"; state.draftNotes = "Cover the happy path"; - const created = { ...sampleCard, id: "card-2", title: "Write tests" }; + state.draftSessionKey = "agent:main:dashboard:1"; + const created = { + ...sampleCard, + id: "card-2", + title: "Write tests", + sessionKey: "agent:main:dashboard:1", + }; const client = createClient({ "workboard.cards.create": { card: created } }); await createWorkboardCard({ host, client: client as never }); @@ -52,9 +73,11 @@ describe("workboard controller", () => { notes: "Cover the happy path", priority: "normal", agentId: "", + sessionKey: "agent:main:dashboard:1", }); expect(state.cards[0]).toMatchObject({ id: "card-2", title: "Write tests" }); expect(state.draftOpen).toBe(false); + expect(state.draftSessionKey).toBe(""); }); it("starts a session and links it back to the card", async () => { @@ -100,4 +123,174 @@ describe("workboard controller", () => { position: 2000, }); }); + + it("derives lifecycle state from linked dashboard sessions", () => { + expect(getWorkboardLifecycle(sampleCard, [sampleSession])).toEqual({ + session: null, + state: "unlinked", + }); + + const linked = { ...sampleCard, sessionKey: sampleSession.key }; + expect(getWorkboardLifecycle(linked, [sampleSession])).toMatchObject({ + state: "running", + targetStatus: "running", + }); + expect( + getWorkboardLifecycle(linked, [{ ...sampleSession, hasActiveRun: false, status: "done" }]), + ).toMatchObject({ + state: "succeeded", + targetStatus: "review", + }); + expect( + getWorkboardLifecycle(linked, [{ ...sampleSession, hasActiveRun: false, status: "failed" }]), + ).toMatchObject({ + state: "failed", + targetStatus: "blocked", + }); + }); + + it("syncs linked card status from session lifecycle without overriding manual review", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { ...sampleCard, sessionKey: sampleSession.key }, + { ...sampleCard, id: "card-review", status: "review", sessionKey: "session-review" }, + ]; + const client = createClient((method) => { + if (method === "workboard.cards.update") { + return { card: { ...sampleCard, status: "running", sessionKey: sampleSession.key } }; + } + return {}; + }); + + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [ + sampleSession, + { ...sampleSession, key: "session-review", status: "failed", hasActiveRun: false }, + ], + }); + + expect(client.request).toHaveBeenCalledOnce(); + expect(client.request).toHaveBeenCalledWith("workboard.cards.update", { + id: "card-1", + patch: { status: "running" }, + }); + expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review"); + }); + + it("resyncs cards manually moved back to an active lifecycle column", async () => { + const host = {}; + const state = getWorkboardState(host); + const linked = { + ...sampleCard, + status: "running", + sessionKey: sampleSession.key, + updatedAt: 1000, + } as const; + const completedSession = { + ...sampleSession, + hasActiveRun: false, + status: "done", + updatedAt: 2000, + } as const; + state.loaded = true; + state.cards = [linked]; + const client = createClient({ + "workboard.cards.update": { + card: { ...linked, status: "review", updatedAt: 3000 }, + }, + }); + + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [completedSession], + }); + state.cards = [{ ...linked, updatedAt: 4000 }]; + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [completedSession], + }); + + expect(client.request).toHaveBeenCalledTimes(2); + }); + + it("does not retry a failed lifecycle sync for the same card and session state", async () => { + const host = {}; + const state = getWorkboardState(host); + const linked = { + ...sampleCard, + status: "running", + sessionKey: sampleSession.key, + updatedAt: 1000, + } as const; + const completedSession = { + ...sampleSession, + hasActiveRun: false, + status: "done", + updatedAt: 2000, + } as const; + state.loaded = true; + state.cards = [linked]; + const client = createClient(() => { + throw new Error("write denied"); + }); + + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [completedSession], + }); + await syncWorkboardLifecycle({ + host, + client: client as never, + sessions: [completedSession], + }); + + expect(client.request).toHaveBeenCalledOnce(); + expect(state.error).toBe("write denied"); + }); + + it("stops linked sessions and marks cards blocked", async () => { + const host = {}; + const linked = { ...sampleCard, sessionKey: sampleSession.key, runId: "run-1" }; + const blocked = { ...linked, status: "blocked" }; + const client = createClient({ + "chat.abort": { aborted: true, runIds: ["run-1"] }, + "workboard.cards.update": { card: blocked }, + }); + + await stopWorkboardCard({ host, client: client as never, card: linked }); + + expect(client.request).toHaveBeenNthCalledWith(1, "chat.abort", { + sessionKey: sampleSession.key, + }); + expect(client.request).toHaveBeenNthCalledWith(2, "workboard.cards.update", { + id: "card-1", + patch: { status: "blocked" }, + }); + expect(getWorkboardState(host).cards[0]).toMatchObject({ status: "blocked" }); + }); + + it("leaves cards unchanged when stop does not abort an active run", async () => { + const host = {}; + const linked = { ...sampleCard, sessionKey: sampleSession.key, runId: "stale-run" }; + const state = getWorkboardState(host); + state.cards = [linked]; + const client = createClient({ + "chat.abort": { aborted: false, runIds: [] }, + }); + + await stopWorkboardCard({ host, client: client as never, card: linked }); + + expect(client.request).toHaveBeenCalledOnce(); + expect(client.request).toHaveBeenCalledWith("chat.abort", { + sessionKey: sampleSession.key, + }); + expect(state.cards).toEqual([linked]); + }); }); diff --git a/ui/src/ui/controllers/workboard.ts b/ui/src/ui/controllers/workboard.ts index 316407d5eb5..fe9afb838d0 100644 --- a/ui/src/ui/controllers/workboard.ts +++ b/ui/src/ui/controllers/workboard.ts @@ -34,6 +34,20 @@ export type WorkboardCard = { completedAt?: number; }; +export type WorkboardLifecycleState = + | "unlinked" + | "missing" + | "idle" + | "running" + | "succeeded" + | "failed"; + +export type WorkboardLifecycle = { + session: GatewaySessionRow | null; + state: WorkboardLifecycleState; + targetStatus?: WorkboardStatus; +}; + export type WorkboardUiState = { loading: boolean; loaded: boolean; @@ -48,8 +62,10 @@ export type WorkboardUiState = { draftNotes: string; draftPriority: WorkboardPriority; draftAgentId: string; + draftSessionKey: string; busyCardId: string | null; draggedCardId: string | null; + syncingCardIds: Set; }; type WorkboardHost = object; @@ -71,8 +87,10 @@ function createDefaultState(): WorkboardUiState { draftNotes: "", draftPriority: "normal", draftAgentId: "", + draftSessionKey: "", busyCardId: null, draggedCardId: null, + syncingCardIds: new Set(), }; } @@ -196,6 +214,109 @@ function replaceCard(state: WorkboardUiState, card: WorkboardCard) { state.cards = next.toSorted((left, right) => left.position - right.position); } +function isFailedSessionStatus(status: GatewaySessionRow["status"]): boolean { + return status === "failed" || status === "killed" || status === "timeout"; +} + +export function getWorkboardLifecycle( + card: WorkboardCard, + sessions: readonly GatewaySessionRow[], +): WorkboardLifecycle { + const session = findWorkboardSession(card, sessions); + if (!card.sessionKey) { + return { session: null, state: "unlinked" }; + } + if (!session) { + return { session: null, state: "missing" }; + } + if (session.hasActiveRun === true || session.status === "running") { + return { session, state: "running", targetStatus: "running" }; + } + if (session.abortedLastRun || isFailedSessionStatus(session.status)) { + return { session, state: "failed", targetStatus: "blocked" }; + } + if (session.status === "done") { + return { session, state: "succeeded", targetStatus: "review" }; + } + return { session, state: "idle" }; +} + +function shouldSyncCardStatus(card: WorkboardCard, targetStatus: WorkboardStatus | undefined) { + if (!targetStatus || card.status === targetStatus) { + return false; + } + if (targetStatus === "running") { + return card.status === "backlog" || card.status === "todo"; + } + if (targetStatus === "blocked" || targetStatus === "review") { + return card.status === "running" || card.status === "todo"; + } + return false; +} + +function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): string { + const session = lifecycle.session; + return [ + card.id, + card.status, + card.updatedAt, + lifecycle.targetStatus ?? "", + session?.status ?? "", + session?.hasActiveRun === true ? "active" : "idle", + session?.updatedAt ?? "", + ].join(":"); +} + +const lifecycleSyncKeys = new WeakMap>(); + +function getLifecycleSyncKeys(host: WorkboardHost): Map { + let keys = lifecycleSyncKeys.get(host); + if (!keys) { + keys = new Map(); + lifecycleSyncKeys.set(host, keys); + } + return keys; +} + +export async function syncWorkboardLifecycle(params: { + host: WorkboardHost; + client: GatewayBrowserClient | null; + sessions: readonly GatewaySessionRow[]; + requestUpdate?: () => void; +}) { + const state = getWorkboardState(params.host); + if (!params.client || !state.loaded) { + return; + } + const syncKeys = getLifecycleSyncKeys(params.host); + for (const card of state.cards) { + const lifecycle = getWorkboardLifecycle(card, params.sessions); + if (!shouldSyncCardStatus(card, lifecycle.targetStatus)) { + continue; + } + const key = lifecycleSyncKey(card, lifecycle); + if (syncKeys.get(card.id) === key || state.syncingCardIds.has(card.id)) { + continue; + } + state.syncingCardIds.add(card.id); + params.requestUpdate?.(); + try { + const payload = await params.client.request("workboard.cards.update", { + id: card.id, + patch: { status: lifecycle.targetStatus }, + }); + replaceCard(state, normalizeCardPayload(payload)); + syncKeys.set(card.id, key); + } catch (error) { + state.error = formatError(error); + syncKeys.set(card.id, key); + } finally { + state.syncingCardIds.delete(card.id); + params.requestUpdate?.(); + } + } +} + export async function createWorkboardCard(params: { host: WorkboardHost; client: GatewayBrowserClient | null; @@ -214,6 +335,7 @@ export async function createWorkboardCard(params: { notes: state.draftNotes, priority: state.draftPriority, agentId: state.draftAgentId, + sessionKey: state.draftSessionKey, }); replaceCard(state, normalizeCardPayload(payload)); state.draftOpen = false; @@ -221,6 +343,7 @@ export async function createWorkboardCard(params: { state.draftNotes = ""; state.draftPriority = "normal"; state.draftAgentId = ""; + state.draftSessionKey = ""; } catch (error) { state.error = formatError(error); } finally { @@ -342,6 +465,43 @@ export async function startWorkboardCard(params: { } } +export async function stopWorkboardCard(params: { + host: WorkboardHost; + client: GatewayBrowserClient | null; + card: WorkboardCard; + requestUpdate?: () => void; +}) { + const state = getWorkboardState(params.host); + if (!params.client || !params.card.sessionKey) { + return; + } + state.busyCardId = params.card.id; + state.error = null; + params.requestUpdate?.(); + try { + const abortResult = await params.client.request("chat.abort", { + sessionKey: params.card.sessionKey, + }); + const aborted = + isRecord(abortResult) && + (abortResult.aborted === true || + (Array.isArray(abortResult.runIds) && abortResult.runIds.length > 0)); + if (!aborted) { + return; + } + const payload = await params.client.request("workboard.cards.update", { + id: params.card.id, + patch: { status: "blocked" }, + }); + replaceCard(state, normalizeCardPayload(payload)); + } catch (error) { + state.error = formatError(error); + } finally { + state.busyCardId = null; + params.requestUpdate?.(); + } +} + export function findWorkboardSession( card: WorkboardCard, sessions: readonly GatewaySessionRow[], diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/ui/views/workboard.test.ts index 1d68fda2513..7182af21244 100644 --- a/ui/src/ui/views/workboard.test.ts +++ b/ui/src/ui/views/workboard.test.ts @@ -21,6 +21,7 @@ describe("renderWorkboard", () => { position: 1000, createdAt: 1, updatedAt: 1, + sessionKey: "agent:main:dashboard:1", }, ]; const container = document.createElement("div"); @@ -32,7 +33,16 @@ describe("renderWorkboard", () => { connected: true, pluginEnabled: true, agentsList: null, - sessions: [], + sessions: [ + { + key: "agent:main:dashboard:1", + kind: "direct", + displayName: "Dashboard session", + updatedAt: 2, + hasActiveRun: true, + status: "running", + }, + ], onOpenSession: () => undefined, }), container, @@ -40,10 +50,43 @@ describe("renderWorkboard", () => { expect(container.textContent).toContain("Todo"); expect(container.textContent).toContain("Wire dashboard tab"); + expect(container.textContent).toContain("Running"); + expect(container.textContent).toContain("Dashboard session"); expect(container.querySelectorAll(".workboard-column")).toHaveLength(6); expect(container.querySelector(".workboard-card__priority")?.textContent).toContain("high"); }); + it("offers existing sessions when creating a card", () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.draftOpen = true; + const container = document.createElement("div"); + + render( + renderWorkboard({ + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [ + { + key: "agent:main:dashboard:1", + kind: "direct", + displayName: "Existing session", + updatedAt: 2, + }, + ], + onOpenSession: () => undefined, + }), + container, + ); + + expect(container.textContent).toContain("No linked session"); + expect(container.textContent).toContain("Existing session"); + }); + it("shows an enablement message when the optional plugin is disabled", () => { const container = document.createElement("div"); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts index 1b8959d5ef5..7d8c75d9270 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/ui/views/workboard.ts @@ -4,12 +4,16 @@ import { createWorkboardCard, deleteWorkboardCard, findWorkboardSession, + getWorkboardLifecycle, getWorkboardState, loadWorkboard, moveWorkboardCard, startWorkboardCard, + stopWorkboardCard, + syncWorkboardLifecycle, WORKBOARD_PRIORITIES, type WorkboardCard, + type WorkboardLifecycle, type WorkboardPriority, type WorkboardStatus, type WorkboardUiState, @@ -72,6 +76,7 @@ function nextPosition(cards: readonly WorkboardCard[], status: WorkboardStatus): function renderDraft(props: WorkboardProps) { const state = getWorkboardState(props.host); const agents = props.agentsList?.agents ?? []; + const sessions = props.sessions.filter((session) => !session.archived); if (!state.draftOpen) { return nothing; } @@ -137,6 +142,22 @@ function renderDraft(props: WorkboardProps) { `, )} + @@ -155,11 +176,74 @@ function renderDraft(props: WorkboardProps) { `; } +function formatLifecycle(lifecycle: WorkboardLifecycle): { + label: string; + detail: string; + tone: "blocked" | "done" | "idle" | "live"; +} { + switch (lifecycle.state) { + case "running": + return { + label: t("workboard.lifecycleRunning"), + detail: t("workboard.lifecycleRunningDetail"), + tone: "live", + }; + case "succeeded": + return { + label: t("workboard.lifecycleDone"), + detail: t("workboard.lifecycleDoneDetail"), + tone: "done", + }; + case "failed": + return { + label: t("workboard.lifecycleNeedsReview"), + detail: t("workboard.lifecycleNeedsReviewDetail"), + tone: "blocked", + }; + case "idle": + return { + label: t("workboard.lifecycleLinked"), + detail: t("workboard.lifecycleIdleDetail"), + tone: "idle", + }; + case "missing": + return { + label: t("workboard.lifecycleMissing"), + detail: t("workboard.lifecycleMissingDetail"), + tone: "blocked", + }; + case "unlinked": + return { + label: t("workboard.lifecycleUnlinked"), + detail: t("workboard.lifecycleUnlinkedDetail"), + tone: "idle", + }; + } + throw new Error("Unknown workboard lifecycle state."); +} + +function renderLifecycle(card: WorkboardCard, sessions: readonly GatewaySessionRow[]) { + const lifecycle = getWorkboardLifecycle(card, sessions); + const formatted = formatLifecycle(lifecycle); + const session = lifecycle.session; + return html` +
+ + ${formatted.label} + + + ${session?.displayName ?? session?.label ?? formatted.detail} + +
+ `; +} + function renderCard(props: WorkboardProps, card: WorkboardCard) { const state = getWorkboardState(props.host); const session = findWorkboardSession(card, props.sessions); const busy = state.busyCardId === card.id; - const live = session?.hasActiveRun === true || card.status === "running"; + const syncing = state.syncingCardIds.has(card.id); + const live = session?.hasActiveRun === true; return html`
${card.priority} ${live ? html`live` : nothing} + ${syncing ? html`${t("common.saving")}` : nothing}

${card.title}

- ${card.notes ? html`

${card.notes}

` : nothing} + ${card.notes ? html`

${card.notes}

` : nothing} ${renderLifecycle(card, props.sessions)} ${card.labels.length ? html`
${card.labels.map((label) => html`${label}`)} @@ -200,6 +285,24 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) { > ${icons.messageSquare} + ${live + ? html` + + ` + : nothing} ` : html`