fix(workboard): polish card editing flow

This commit is contained in:
Peter Steinberger
2026-05-22 19:08:04 +01:00
parent 63111746b1
commit 0cdb80078f
9 changed files with 940 additions and 143 deletions

View File

@@ -68,4 +68,38 @@ describe("workboard gateway methods", () => {
cards: [expect.objectContaining({ title: "Investigate queue drift" })],
});
});
it("validates labels from comma-separated gateway input", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
registerWorkboardGatewayMethods({ api });
const createHandler = methods.get("workboard.cards.create")?.handler;
const respond = vi.fn();
await createHandler?.({
params: { title: "Check labels", labels: `valid, ${"x".repeat(41)}` },
respond,
} as never);
expect(respond.mock.calls[0]?.[0]).toBe(false);
expect(respond.mock.calls[0]?.[2]).toMatchObject({
message: "labels must be 40 characters or fewer.",
});
});
});

View File

@@ -88,18 +88,13 @@ function normalizeLabels(value: unknown, fallback: string[] = []): string[] {
if (value == null) {
return fallback;
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 12);
}
if (!Array.isArray(value)) {
const entries =
typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : undefined;
if (!entries) {
throw new Error("labels must be an array or comma-separated string.");
}
const labels: string[] = [];
for (const entry of value) {
for (const entry of entries) {
const label = normalizeOptionalString(entry);
if (!label || labels.includes(label)) {
continue;

View File

@@ -1226,6 +1226,17 @@
margin-top: 0;
}
.content--workboard {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 16px;
}
.content--workboard > * + * {
margin-top: 14px;
}
/* Content header */
.content-header {
display: flex;

View File

@@ -1,16 +1,28 @@
.workboard {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
flex: 1;
min-height: 0;
--workboard-control-height: 34px;
--workboard-control-radius: 7px;
--workboard-control-bg: color-mix(in srgb, var(--bg-elevated) 72%, var(--bg) 28%);
--workboard-control-border: color-mix(in srgb, var(--border-strong) 78%, transparent);
--workboard-control-border-hover: color-mix(in srgb, var(--accent) 36%, var(--border-strong));
}
.workboard-toolbar,
.workboard-draft {
.workboard-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
gap: 10px;
align-items: center;
}
.workboard-toolbar {
padding: 8px;
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 84%, transparent);
}
.workboard-toolbar__filters,
@@ -28,55 +40,193 @@
.workboard-toolbar__filters {
flex: 1;
min-width: 260px;
}
.workboard .input {
min-height: var(--workboard-control-height);
border: 1px solid var(--workboard-control-border);
border-radius: var(--workboard-control-radius);
background-color: var(--workboard-control-bg);
color: var(--text);
font-size: 13px;
line-height: 1.25;
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
transition:
border-color var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.workboard .input:hover {
border-color: var(--workboard-control-border-hover);
}
.workboard .input:focus {
border-color: color-mix(in srgb, var(--accent) 70%, var(--border-strong));
outline: none;
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 5%, transparent),
0 0 0 2px color-mix(in srgb, var(--accent) 16%, transparent);
}
.workboard input.input,
.workboard select.input {
height: var(--workboard-control-height);
padding: 0 10px;
}
.workboard select.input {
max-width: 220px;
padding-right: 28px;
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position:
calc(100% - 14px) 50%,
calc(100% - 9px) 50%;
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
}
.workboard input.input::placeholder,
.workboard textarea.input::placeholder {
color: color-mix(in srgb, var(--muted) 76%, transparent);
opacity: 1;
}
.workboard-toolbar__filters .input[type="search"] {
min-width: min(340px, 100%);
width: min(360px, 100%);
min-width: min(260px, 100%);
}
.workboard-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 28px;
background: color-mix(in srgb, #000 60%, transparent);
backdrop-filter: blur(8px);
}
.workboard-draft {
padding: 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
display: grid;
gap: 14px;
width: min(760px, calc(100vw - 44px));
max-height: min(820px, calc(100vh - 56px));
overflow: auto;
padding: 16px;
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--panel) 96%, var(--bg) 4%);
box-shadow: var(--shadow-xl);
}
.workboard-modal__header {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-start;
}
.workboard-modal__header h2 {
margin: 0;
font-size: 1rem;
line-height: 1.25;
}
.workboard-modal__header p {
margin: 3px 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.workboard-draft__main {
display: grid;
gap: 8px;
flex: 1;
min-width: 220px;
gap: 10px;
min-width: 0;
}
.workboard-draft__title {
font-weight: 650;
width: 100%;
}
.workboard-draft__notes {
min-height: 76px;
.workboard textarea.input.workboard-draft__notes {
width: 100%;
height: 156px;
min-height: 156px;
padding: 9px 10px;
resize: vertical;
}
.workboard-draft__meta {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 10px;
}
.workboard-field {
display: grid;
gap: 5px;
min-width: 0;
}
.workboard-field span {
color: var(--muted);
font-size: 0.72rem;
font-weight: 650;
line-height: 1.2;
text-transform: uppercase;
}
.workboard-field .input {
width: 100%;
max-width: none;
}
.workboard-field--wide {
grid-column: 1 / -1;
}
.workboard-modal__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.workboard-toolbar .btn,
.workboard-draft .btn {
min-height: var(--workboard-control-height);
padding: 0 12px;
border-radius: var(--workboard-control-radius);
}
.workboard-board {
display: grid;
grid-template-columns: repeat(6, minmax(220px, 1fr));
gap: 12px;
flex: 1;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
}
.workboard-column {
min-height: 440px;
min-height: 0;
background: color-mix(in srgb, var(--panel) 78%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: 8px;
display: flex;
flex-direction: column;
min-width: 220px;
overflow: hidden;
}
.workboard-column--drop {
@@ -105,11 +255,13 @@
}
.workboard-column__cards {
flex: 1;
min-height: 0;
padding: 10px;
display: grid;
gap: 10px;
align-content: start;
min-height: 100%;
overflow-y: auto;
}
.workboard-card {
@@ -120,6 +272,25 @@
display: grid;
gap: 8px;
box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 60%, transparent);
transition:
border-color var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.workboard-card--openable {
cursor: pointer;
}
.workboard-card--openable:hover {
border-color: color-mix(in srgb, var(--accent) 34%, var(--border-strong));
background: color-mix(in srgb, var(--bg-elevated) 54%, var(--bg) 46%);
}
.workboard-card--openable:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 56%, transparent);
outline-offset: 2px;
border-color: var(--accent);
}
.workboard-card--busy {
@@ -154,6 +325,32 @@
justify-content: flex-end;
}
.workboard-card__icon {
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
color: var(--muted);
}
.workboard-card__icon svg {
width: 15px;
height: 15px;
}
.workboard-card__delete:hover {
border-color: color-mix(in srgb, var(--danger) 34%, var(--border));
background: color-mix(in srgb, var(--danger) 14%, transparent);
color: var(--danger);
}
.workboard-card__start {
min-height: 28px;
padding: 4px 9px;
border-radius: 6px;
}
.workboard-card__lifecycle {
display: flex;
align-items: center;
@@ -174,11 +371,22 @@
.workboard-live,
.workboard-lifecycle,
.workboard-labels span {
display: inline-flex;
align-items: center;
min-height: 20px;
border-radius: 999px;
padding: 2px 7px;
background: color-mix(in srgb, var(--border) 60%, transparent);
color: var(--muted);
font-size: 0.72rem;
line-height: 1;
white-space: nowrap;
}
.workboard-lifecycle {
flex: 0 0 auto;
border-radius: 6px;
padding-inline: 8px;
}
.priority-high .workboard-card__priority,
@@ -222,17 +430,30 @@
}
@media (max-width: 860px) {
.workboard-toolbar,
.workboard-draft {
.workboard-toolbar {
display: flex;
flex-direction: column;
align-items: stretch;
}
.workboard-toolbar__filters,
.workboard-toolbar__actions,
.workboard-draft__meta {
width: 100%;
justify-content: flex-start;
}
.workboard-toolbar__filters .input[type="search"],
.workboard-toolbar__filters .input,
.workboard-draft__meta .input {
width: 100%;
max-width: none;
}
.workboard-draft__meta {
grid-template-columns: 1fr;
}
.workboard-board {
grid-template-columns: repeat(6, minmax(260px, 82vw));
}

View File

@@ -1849,7 +1849,7 @@ export function renderApp(state: AppViewState) {
<main
class="content ${isChat ? "content--chat" : ""} ${state.tab === "logs"
? "content--logs"
: ""}"
: ""} ${state.tab === "workboard" ? "content--workboard" : ""}"
>
${state.updateStatusBanner
? html`<div class="callout ${state.updateStatusBanner.tone}" role="alert">

View File

@@ -7,6 +7,7 @@ import {
getWorkboardState,
loadWorkboard,
moveWorkboardCard,
saveWorkboardCardDraft,
startWorkboardCard,
stopWorkboardCard,
syncWorkboardLifecycle,
@@ -83,7 +84,9 @@ describe("workboard controller", () => {
expect(client.request).toHaveBeenCalledWith("workboard.cards.create", {
title: "Write tests",
notes: "Cover the happy path",
status: "todo",
priority: "normal",
labels: [],
agentId: "",
sessionKey: "agent:main:dashboard:1",
});
@@ -92,6 +95,50 @@ describe("workboard controller", () => {
expect(state.draftSessionKey).toBe("");
});
it("updates cards from draft state when editing", async () => {
const host = {};
const state = getWorkboardState(host);
state.cards = [sampleCard];
state.draftOpen = true;
state.editingCardId = sampleCard.id;
state.draftTitle = "Updated board";
state.draftNotes = "New notes";
state.draftStatus = "review";
state.draftPriority = "high";
state.draftLabels = "ui, polish";
state.draftAgentId = "dev";
state.draftSessionKey = sampleSession.key;
const updated = {
...sampleCard,
title: "Updated board",
notes: "New notes",
status: "review",
priority: "high",
labels: ["ui", "polish"],
agentId: "dev",
sessionKey: sampleSession.key,
};
const client = createClient({ "workboard.cards.update": { card: updated } });
await saveWorkboardCardDraft({ host, client: client as never });
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: {
title: "Updated board",
notes: "New notes",
status: "review",
priority: "high",
labels: ["ui", "polish"],
agentId: "dev",
sessionKey: sampleSession.key,
},
});
expect(state.cards[0]).toMatchObject({ title: "Updated board", status: "review" });
expect(state.draftOpen).toBe(false);
expect(state.editingCardId).toBeNull();
});
it("captures existing sessions as linked workboard cards", async () => {
const host = {};
const session = {
@@ -285,6 +332,14 @@ describe("workboard controller", () => {
});
expect(sessionKey).toBe("agent:main:dashboard:1");
expect(client.request).toHaveBeenNthCalledWith(
1,
"sessions.create",
expect.objectContaining({
label: "Build board (card-1)",
message: expect.stringContaining("Work on this OpenClaw Workboard card: Build board"),
}),
);
expect(client.request).toHaveBeenNthCalledWith(
2,
"workboard.cards.update",

View File

@@ -58,9 +58,12 @@ export type WorkboardUiState = {
query: string;
priorityFilter: "all" | WorkboardPriority;
draftOpen: boolean;
editingCardId: string | null;
draftTitle: string;
draftNotes: string;
draftStatus: WorkboardStatus;
draftPriority: WorkboardPriority;
draftLabels: string;
draftAgentId: string;
draftSessionKey: string;
busyCardId: string | null;
@@ -77,6 +80,7 @@ const SESSION_CAPTURE_HISTORY_LIMIT = 40;
const SESSION_CAPTURE_HISTORY_MAX_CHARS = 6000;
const SESSION_CAPTURE_TEXT_MAX_CHARS = 700;
const WORKBOARD_CAPTURE_TITLE_MAX_CHARS = 180;
const WORKBOARD_SESSION_LABEL_MAX_CHARS = 512;
function createDefaultState(): WorkboardUiState {
return {
@@ -89,9 +93,12 @@ function createDefaultState(): WorkboardUiState {
query: "",
priorityFilter: "all",
draftOpen: false,
editingCardId: null,
draftTitle: "",
draftNotes: "",
draftStatus: "todo",
draftPriority: "normal",
draftLabels: "",
draftAgentId: "",
draftSessionKey: "",
busyCardId: null,
@@ -232,6 +239,44 @@ function replaceCard(state: WorkboardUiState, card: WorkboardCard) {
state.cards = next.toSorted((left, right) => left.position - right.position);
}
function resetDraftState(state: WorkboardUiState) {
state.draftOpen = false;
state.editingCardId = null;
state.draftTitle = "";
state.draftNotes = "";
state.draftStatus = "todo";
state.draftPriority = "normal";
state.draftLabels = "";
state.draftAgentId = "";
state.draftSessionKey = "";
}
function normalizeDraftLabels(value: string): string[] {
const labels: string[] = [];
for (const label of value.split(",")) {
const trimmed = label.trim();
if (trimmed && !labels.includes(trimmed)) {
labels.push(trimmed);
}
if (labels.length >= 12) {
break;
}
}
return labels;
}
function draftPayload(state: WorkboardUiState) {
return {
title: state.draftTitle,
notes: state.draftNotes,
status: state.draftStatus,
priority: state.draftPriority,
labels: normalizeDraftLabels(state.draftLabels),
agentId: state.draftAgentId,
sessionKey: state.draftSessionKey,
};
}
function isFailedSessionStatus(status: GatewaySessionRow["status"]): boolean {
return status === "failed" || status === "killed" || status === "timeout";
}
@@ -526,20 +571,40 @@ export async function createWorkboardCard(params: {
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.create", {
title: state.draftTitle,
notes: state.draftNotes,
priority: state.draftPriority,
agentId: state.draftAgentId,
sessionKey: state.draftSessionKey,
const payload = await params.client.request("workboard.cards.create", draftPayload(state));
replaceCard(state, normalizeCardPayload(payload));
resetDraftState(state);
} catch (error) {
state.error = formatError(error);
} finally {
state.loading = false;
params.requestUpdate?.();
}
}
export async function saveWorkboardCardDraft(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (!state.editingCardId) {
await createWorkboardCard(params);
return;
}
if (!params.client || !state.draftTitle.trim()) {
return;
}
state.loading = true;
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.update", {
id: state.editingCardId,
patch: draftPayload(state),
});
replaceCard(state, normalizeCardPayload(payload));
state.draftOpen = false;
state.draftTitle = "";
state.draftNotes = "";
state.draftPriority = "normal";
state.draftAgentId = "";
state.draftSessionKey = "";
resetDraftState(state);
} catch (error) {
state.error = formatError(error);
} finally {
@@ -615,6 +680,17 @@ function buildCardPrompt(card: WorkboardCard): string {
return lines.join("\n");
}
function buildCardSessionLabel(card: WorkboardCard): string {
const suffix = card.id.trim().slice(0, 8) || "card";
const title = card.title.trim() || "Workboard card";
const suffixText = ` (${suffix})`;
if (title.length + suffixText.length <= WORKBOARD_SESSION_LABEL_MAX_CHARS) {
return `${title}${suffixText}`;
}
const titleMax = WORKBOARD_SESSION_LABEL_MAX_CHARS - suffixText.length;
return `${title.slice(0, titleMax - 3).trimEnd()}...${suffixText}`;
}
export async function startWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
@@ -631,7 +707,7 @@ export async function startWorkboardCard(params: {
try {
const created = await params.client.request("sessions.create", {
...(params.card.agentId ? { agentId: params.card.agentId } : {}),
label: params.card.title,
label: buildCardSessionLabel(params.card),
message: buildCardPrompt(params.card),
});
const sessionKey =

View File

@@ -56,6 +56,201 @@ describe("renderWorkboard", () => {
expect(container.querySelector(".workboard-card__priority")?.textContent).toContain("high");
});
it("opens linked cards from the card surface without hijacking action buttons", () => {
const host = {};
const state = getWorkboardState(host);
const onOpenSession = vi.fn();
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Inspect a running task",
status: "running",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
sessionKey: "agent:main:dashboard:1",
},
];
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: "Dashboard session",
updatedAt: 2,
hasActiveRun: true,
status: "running",
},
],
onOpenSession,
}),
container,
);
const card = container.querySelector<HTMLElement>(".workboard-card");
card?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onOpenSession).toHaveBeenCalledWith("agent:main:dashboard:1");
onOpenSession.mockClear();
container
.querySelector<HTMLButtonElement>('button[title="Delete card"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onOpenSession).not.toHaveBeenCalled();
});
it("shows a labeled start action for unlinked cards", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Start this later",
status: "todo",
priority: "normal",
labels: [],
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
const startButton = container.querySelector<HTMLButtonElement>(".workboard-card__start");
expect(startButton?.textContent).toContain("Start");
expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBeNull();
});
it("opens a modal for new cards", () => {
const host = {};
getWorkboardState(host).loaded = true;
const container = document.createElement("div");
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
container
.querySelector<HTMLButtonElement>(".workboard-toolbar__actions .btn.primary")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(
renderWorkboard({
host,
client: null,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
}),
container,
);
expect(container.querySelector('[role="dialog"]')?.textContent).toContain("New card");
expect(container.querySelector(".workboard-board")).toBeTruthy();
});
it("opens an edit modal and submits card updates", async () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
id: "card-1",
title: "Rename me",
notes: "Old notes",
status: "todo",
priority: "normal",
labels: ["ui"],
position: 1000,
createdAt: 1,
updatedAt: 1,
},
];
const request = vi.fn(async () => ({
card: {
...state.cards[0],
title: "Renamed",
priority: "high",
updatedAt: 2,
},
}));
const props = {
host,
client: { request } as unknown as GatewayBrowserClient,
connected: true,
pluginEnabled: true,
agentsList: null,
sessions: [],
onOpenSession: () => undefined,
onRequestUpdate: () => undefined,
};
const container = document.createElement("div");
render(renderWorkboard(props), container);
container
.querySelector<HTMLButtonElement>('button[title="Edit card"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
render(renderWorkboard(props), container);
expect(container.querySelector('[role="dialog"]')?.textContent).toContain("Edit card");
const title = container.querySelector<HTMLInputElement>(".workboard-draft__title");
expect(title?.value).toBe("Rename me");
title!.value = "Renamed";
title!.dispatchEvent(new InputEvent("input", { bubbles: true }));
const priority = [
...container.querySelectorAll<HTMLSelectElement>(".workboard-draft__meta select"),
].at(1);
priority!.value = "high";
priority!.dispatchEvent(new Event("change", { bubbles: true }));
container
.querySelector<HTMLFormElement>(".workboard-draft")
?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
await Promise.resolve();
expect(request).toHaveBeenCalledWith("workboard.cards.update", {
id: "card-1",
patch: expect.objectContaining({
title: "Renamed",
priority: "high",
}),
});
});
it("offers existing sessions when creating a card", () => {
const host = {};
const state = getWorkboardState(host);
@@ -87,6 +282,49 @@ describe("renderWorkboard", () => {
expect(container.textContent).toContain("Existing session");
});
it("does not offer synthetic heartbeat 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:heartbeat",
kind: "direct",
displayName: "heartbeat",
updatedAt: 2,
},
{
key: "agent:main:dashboard:1",
kind: "direct",
displayName: "Dashboard session",
updatedAt: 3,
},
],
onOpenSession: () => undefined,
}),
container,
);
const sessionOptions = [
...container.querySelectorAll<HTMLSelectElement>(".workboard-draft__meta select"),
].at(3);
const labels = [...(sessionOptions?.querySelectorAll("option") ?? [])].map((option) =>
option.textContent?.trim(),
);
expect(labels).toContain("Dashboard session");
expect(labels).not.toContain("heartbeat");
});
it("shows an enablement message when the optional plugin is disabled", () => {
const container = document.createElement("div");

View File

@@ -1,13 +1,13 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import {
createWorkboardCard,
deleteWorkboardCard,
findWorkboardSession,
getWorkboardLifecycle,
getWorkboardState,
loadWorkboard,
moveWorkboardCard,
saveWorkboardCardDraft,
startWorkboardCard,
stopWorkboardCard,
syncWorkboardLifecycle,
@@ -73,106 +73,244 @@ function nextPosition(cards: readonly WorkboardCard[], status: WorkboardStatus):
return (positions.length ? Math.max(...positions) : 0) + 1000;
}
function renderDraft(props: WorkboardProps) {
function isWorkboardSessionChoice(session: GatewaySessionRow): boolean {
if (session.archived || session.kind === "global") {
return false;
}
const raw = [session.key, session.label, session.displayName]
.filter((value): value is string => typeof value === "string")
.join(":")
.toLowerCase();
return !/(^|:)heartbeat(:|$)/.test(raw);
}
function isCardActionTarget(event: Event): boolean {
return event.target instanceof Element
? Boolean(event.target.closest("button, a, input, select, textarea"))
: false;
}
function openCardSession(
props: Pick<WorkboardProps, "onOpenSession">,
card: WorkboardCard,
): boolean {
if (!card.sessionKey) {
return false;
}
props.onOpenSession(card.sessionKey);
return true;
}
function resetDraft(state: WorkboardUiState) {
state.draftOpen = false;
state.editingCardId = null;
state.draftTitle = "";
state.draftNotes = "";
state.draftStatus = "todo";
state.draftPriority = "normal";
state.draftLabels = "";
state.draftAgentId = "";
state.draftSessionKey = "";
}
function openCreateModal(state: WorkboardUiState) {
resetDraft(state);
state.draftOpen = true;
}
function openEditModal(state: WorkboardUiState, card: WorkboardCard) {
state.draftOpen = true;
state.editingCardId = card.id;
state.draftTitle = card.title;
state.draftNotes = card.notes ?? "";
state.draftStatus = card.status;
state.draftPriority = card.priority;
state.draftLabels = card.labels.join(", ");
state.draftAgentId = card.agentId ?? "";
state.draftSessionKey = card.sessionKey ?? "";
}
function renderCardModal(props: WorkboardProps) {
const state = getWorkboardState(props.host);
const agents = props.agentsList?.agents ?? [];
const sessions = props.sessions.filter((session) => !session.archived);
const sessions = props.sessions.filter(isWorkboardSessionChoice);
if (!state.draftOpen) {
return nothing;
}
const editing = Boolean(state.editingCardId);
return html`
<form
class="workboard-draft"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
void createWorkboardCard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
});
<div
class="workboard-modal"
role="presentation"
@click=${(event: MouseEvent) => {
if (event.target === event.currentTarget) {
resetDraft(state);
props.onRequestUpdate?.();
}
}}
>
<div class="workboard-draft__main">
<input
class="input workboard-draft__title"
placeholder="Card title"
.value=${state.draftTitle}
@input=${(event: InputEvent) => {
state.draftTitle = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
<textarea
class="input workboard-draft__notes"
placeholder="Notes, acceptance criteria, links"
.value=${state.draftNotes}
@input=${(event: InputEvent) => {
state.draftNotes = (event.currentTarget as HTMLTextAreaElement).value;
props.onRequestUpdate?.();
}}
></textarea>
</div>
<div class="workboard-draft__meta">
<select
class="input"
.value=${state.draftPriority}
@change=${(event: Event) => {
state.draftPriority = (event.currentTarget as HTMLSelectElement)
.value as WorkboardPriority;
props.onRequestUpdate?.();
}}
>
${WORKBOARD_PRIORITIES.map(
(priority) => html`<option value=${priority}>${priority}</option>`,
)}
</select>
<select
class="input"
.value=${state.draftAgentId}
@change=${(event: Event) => {
state.draftAgentId = (event.currentTarget as HTMLSelectElement).value;
props.onRequestUpdate?.();
}}
>
<option value="">Default agent</option>
${agents.map(
(agent) =>
html`<option value=${agent.id}>
${agent.name ?? agent.identity?.name ?? agent.id}
</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>
<button
class="btn"
type="button"
@click=${() => {
state.draftOpen = false;
props.onRequestUpdate?.();
}}
>
${t("common.cancel")}
</button>
</div>
</form>
<form
class="workboard-draft"
role="dialog"
aria-modal="true"
aria-labelledby="workboard-card-modal-title"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
void saveWorkboardCardDraft({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
});
}}
>
<div class="workboard-modal__header">
<div>
<h2 id="workboard-card-modal-title">${editing ? "Edit card" : "New card"}</h2>
<p>
${editing
? "Update queue metadata and session handoff."
: "Queue work for an agent session."}
</p>
</div>
<button
class="btn btn--icon workboard-card__icon"
type="button"
title=${t("common.cancel")}
@click=${() => {
resetDraft(state);
props.onRequestUpdate?.();
}}
>
${icons.x}
</button>
</div>
<div class="workboard-draft__main">
<label class="workboard-field">
<span>Title</span>
<input
class="input workboard-draft__title"
placeholder="Card title"
.value=${state.draftTitle}
@input=${(event: InputEvent) => {
state.draftTitle = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
</label>
<label class="workboard-field">
<span>Notes</span>
<textarea
class="input workboard-draft__notes"
placeholder="Notes, acceptance criteria, links"
.value=${state.draftNotes}
@input=${(event: InputEvent) => {
state.draftNotes = (event.currentTarget as HTMLTextAreaElement).value;
props.onRequestUpdate?.();
}}
></textarea>
</label>
</div>
<div class="workboard-draft__meta">
<label class="workboard-field">
<span>Status</span>
<select
class="input"
.value=${state.draftStatus}
@change=${(event: Event) => {
state.draftStatus = (event.currentTarget as HTMLSelectElement)
.value as WorkboardStatus;
props.onRequestUpdate?.();
}}
>
${state.statuses.map(
(status) => html`<option value=${status}>${STATUS_LABELS[status]}</option>`,
)}
</select>
</label>
<label class="workboard-field">
<span>Priority</span>
<select
class="input"
.value=${state.draftPriority}
@change=${(event: Event) => {
state.draftPriority = (event.currentTarget as HTMLSelectElement)
.value as WorkboardPriority;
props.onRequestUpdate?.();
}}
>
${WORKBOARD_PRIORITIES.map(
(priority) => html`<option value=${priority}>${priority}</option>`,
)}
</select>
</label>
<label class="workboard-field">
<span>Agent</span>
<select
class="input"
.value=${state.draftAgentId}
@change=${(event: Event) => {
state.draftAgentId = (event.currentTarget as HTMLSelectElement).value;
props.onRequestUpdate?.();
}}
>
<option value="">Default agent</option>
${agents.map(
(agent) =>
html`<option value=${agent.id}>
${agent.name ?? agent.identity?.name ?? agent.id}
</option>`,
)}
</select>
</label>
<label class="workboard-field">
<span>Session</span>
<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>
</label>
<label class="workboard-field workboard-field--wide">
<span>Labels</span>
<input
class="input"
placeholder="ui, docs"
.value=${state.draftLabels}
@input=${(event: InputEvent) => {
state.draftLabels = (event.currentTarget as HTMLInputElement).value;
props.onRequestUpdate?.();
}}
/>
</label>
</div>
<div class="workboard-modal__actions">
<button class="btn primary" ?disabled=${state.loading || !state.draftTitle.trim()}>
${editing ? t("common.save") : t("common.create")}
</button>
<button
class="btn"
type="button"
@click=${() => {
resetDraft(state);
props.onRequestUpdate?.();
}}
>
${t("common.cancel")}
</button>
</div>
</form>
</div>
`;
}
@@ -244,10 +382,29 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
const busy = state.busyCardId === card.id;
const syncing = state.syncingCardIds.has(card.id);
const live = session?.hasActiveRun === true;
const linked = Boolean(card.sessionKey);
return html`
<article
class="workboard-card priority-${card.priority} ${busy ? "workboard-card--busy" : ""}"
class="workboard-card priority-${card.priority} ${busy ? "workboard-card--busy" : ""} ${linked
? "workboard-card--openable"
: ""}"
role=${linked ? "button" : nothing}
tabindex=${linked ? 0 : nothing}
title=${linked ? "Open linked session" : nothing}
draggable="true"
@click=${(event: MouseEvent) => {
if (!isCardActionTarget(event)) {
openCardSession(props, card);
}
}}
@keydown=${(event: KeyboardEvent) => {
if (isCardActionTarget(event) || (event.key !== "Enter" && event.key !== " ")) {
return;
}
if (openCardSession(props, card)) {
event.preventDefault();
}
}}
@dragstart=${(event: DragEvent) => {
state.draggedCardId = card.id;
event.dataTransfer?.setData("text/plain", card.id);
@@ -276,10 +433,20 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
<span>${formatTime(card.updatedAt)}</span>
</div>
<div class="workboard-card__actions">
<button
class="btn btn--icon workboard-card__icon"
title="Edit card"
@click=${() => {
openEditModal(state, card);
props.onRequestUpdate?.();
}}
>
${icons.edit}
</button>
${card.sessionKey
? html`
<button
class="icon-btn"
class="btn btn--icon workboard-card__icon"
title="Open session"
@click=${() => props.onOpenSession(card.sessionKey!)}
>
@@ -288,7 +455,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
${live
? html`
<button
class="icon-btn"
class="btn btn--icon workboard-card__icon"
title=${t("workboard.stopSession")}
?disabled=${busy || !props.connected}
@click=${() =>
@@ -306,7 +473,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
`
: html`
<button
class="icon-btn"
class="btn btn--xs workboard-card__start"
title="Start session"
?disabled=${busy || !props.connected}
@click=${async () => {
@@ -321,11 +488,11 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) {
}
}}
>
${icons.play}
${icons.play} Start
</button>
`}
<button
class="icon-btn"
class="btn btn--icon workboard-card__icon workboard-card__delete"
title="Delete card"
?disabled=${busy}
@click=${() =>
@@ -466,7 +633,7 @@ export function renderWorkboard(props: WorkboardProps) {
<button
class="btn primary"
@click=${() => {
state.draftOpen = true;
openCreateModal(state);
props.onRequestUpdate?.();
}}
>
@@ -475,7 +642,7 @@ export function renderWorkboard(props: WorkboardProps) {
</div>
</div>
${state.error ? html`<div class="callout danger">${state.error}</div>` : nothing}
${renderDraft(props)}
${renderCardModal(props)}
<div class="workboard-board">
${state.statuses.map((status) => renderColumn(props, status, byStatus.get(status) ?? []))}
</div>