mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 18:22:55 +00:00
fix(workboard): polish card editing flow
This commit is contained in:
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user