mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 13:34:06 +00:00
feat: sync workboard cards with sessions
This commit is contained in:
@@ -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:
|
||||
|
||||
14
ui/src/i18n/locales/ar.ts
generated
14
ui/src/i18n/locales/ar.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/de.ts
generated
14
ui/src/i18n/locales/de.ts
generated
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/es.ts
generated
14
ui/src/i18n/locales/es.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/fa.ts
generated
14
ui/src/i18n/locales/fa.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/fr.ts
generated
14
ui/src/i18n/locales/fr.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/id.ts
generated
14
ui/src/i18n/locales/id.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/it.ts
generated
14
ui/src/i18n/locales/it.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/ja-JP.ts
generated
14
ui/src/i18n/locales/ja-JP.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/ko.ts
generated
14
ui/src/i18n/locales/ko.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/nl.ts
generated
14
ui/src/i18n/locales/nl.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/pl.ts
generated
14
ui/src/i18n/locales/pl.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/pt-BR.ts
generated
14
ui/src/i18n/locales/pt-BR.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/th.ts
generated
14
ui/src/i18n/locales/th.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/tr.ts
generated
14
ui/src/i18n/locales/tr.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/uk.ts
generated
14
ui/src/i18n/locales/uk.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/vi.ts
generated
14
ui/src/i18n/locales/vi.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/zh-CN.ts
generated
14
ui/src/i18n/locales/zh-CN.ts
generated
@@ -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: {
|
||||
|
||||
14
ui/src/i18n/locales/zh-TW.ts
generated
14
ui/src/i18n/locales/zh-TW.ts
generated
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
const request = vi.fn(async (method: string) => responses[method]);
|
||||
function createClient(responses: Record<string, unknown> | ((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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>;
|
||||
};
|
||||
|
||||
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<WorkboardHost, Map<string, string>>();
|
||||
|
||||
function getLifecycleSyncKeys(host: WorkboardHost): Map<string, string> {
|
||||
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[],
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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) {
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
<select
|
||||
class="input"
|
||||
.value=${state.draftSessionKey}
|
||||
@change=${(event: Event) => {
|
||||
state.draftSessionKey = (event.currentTarget as HTMLSelectElement).value;
|
||||
props.onRequestUpdate?.();
|
||||
}}
|
||||
>
|
||||
<option value="">${t("workboard.noLinkedSession")}</option>
|
||||
${sessions.map(
|
||||
(session) =>
|
||||
html`<option value=${session.key}>
|
||||
${session.displayName ?? session.label ?? session.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
<button class="btn primary" ?disabled=${state.loading || !state.draftTitle.trim()}>
|
||||
${t("common.create")}
|
||||
</button>
|
||||
@@ -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`
|
||||
<div class="workboard-card__lifecycle">
|
||||
<span class="workboard-lifecycle workboard-lifecycle--${formatted.tone}">
|
||||
${formatted.label}
|
||||
</span>
|
||||
<span class="workboard-card__lifecycle-detail">
|
||||
${session?.displayName ?? session?.label ?? formatted.detail}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<article
|
||||
class="workboard-card priority-${card.priority} ${busy ? "workboard-card--busy" : ""}"
|
||||
@@ -178,9 +262,10 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
|
||||
<div class="workboard-card__top">
|
||||
<span class="workboard-card__priority">${card.priority}</span>
|
||||
${live ? html`<span class="workboard-live">live</span>` : nothing}
|
||||
${syncing ? html`<span class="workboard-live">${t("common.saving")}</span>` : nothing}
|
||||
</div>
|
||||
<h3>${card.title}</h3>
|
||||
${card.notes ? html`<p>${card.notes}</p>` : nothing}
|
||||
${card.notes ? html`<p>${card.notes}</p>` : nothing} ${renderLifecycle(card, props.sessions)}
|
||||
${card.labels.length
|
||||
? html`<div class="workboard-labels">
|
||||
${card.labels.map((label) => html`<span>${label}</span>`)}
|
||||
@@ -200,6 +285,24 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
|
||||
>
|
||||
${icons.messageSquare}
|
||||
</button>
|
||||
${live
|
||||
? html`
|
||||
<button
|
||||
class="icon-btn"
|
||||
title=${t("workboard.stopSession")}
|
||||
?disabled=${busy || !props.connected}
|
||||
@click=${() =>
|
||||
stopWorkboardCard({
|
||||
host: props.host,
|
||||
client: props.client,
|
||||
card,
|
||||
requestUpdate: props.onRequestUpdate,
|
||||
})}
|
||||
>
|
||||
${icons.stop}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
@@ -287,6 +390,12 @@ export function renderWorkboard(props: WorkboardProps) {
|
||||
client: props.client,
|
||||
requestUpdate: props.onRequestUpdate,
|
||||
});
|
||||
void syncWorkboardLifecycle({
|
||||
host: props.host,
|
||||
client: props.client,
|
||||
sessions: props.sessions,
|
||||
requestUpdate: props.onRequestUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
if (!props.pluginEnabled) {
|
||||
|
||||
Reference in New Issue
Block a user