feat: sync workboard cards with sessions

This commit is contained in:
Peter Steinberger
2026-05-22 16:03:12 +01:00
parent 8a04851fa0
commit 024cd0e4aa
27 changed files with 837 additions and 10 deletions

View File

@@ -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:

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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 dexé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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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);

View File

@@ -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]);
});
});

View File

@@ -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[],

View File

@@ -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");

View File

@@ -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) {