diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md index 562c1cffe9f7..b77eeb2a873b 100644 --- a/docs/plugins/workboard.md +++ b/docs/plugins/workboard.md @@ -292,7 +292,8 @@ Workboard stops auto-moving that card until you move it back to `todo` or 2. Create a card with a title, notes, priority, labels, optional agent, and optional linked session. 3. Or open Sessions and choose Add to Workboard for an existing session. -4. Drag the card between columns or use the column controls. +4. Drag the card between columns or focus the compact status control on the card + and use its menu or ArrowLeft/ArrowRight. 5. Start work from the card to create or reuse a dashboard session. 6. Open the linked session from the card while the agent works. 7. Let lifecycle sync move running work into review or blocked, then manually diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index f5ecd5372fa3..c1cf7147c6d0 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -369,6 +369,73 @@ height: 15px; } +.workboard-card__move { + position: relative; + display: inline-flex; + align-items: center; + color: var(--muted); +} + +.workboard-card__move-icon { + position: absolute; + left: 7px; + display: inline-flex; + pointer-events: none; +} + +.workboard-card__move-icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.workboard-card__move-select { + height: 28px; + max-width: 112px; + min-width: 92px; + padding: 0 22px 0 25px; + border: 1px solid var(--border); + border-radius: 6px; + appearance: none; + background-color: var(--bg-elevated); + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + calc(100% - 13px) 50%, + calc(100% - 8px) 50%; + background-size: + 5px 5px, + 5px 5px; + background-repeat: no-repeat; + color: var(--muted); + cursor: pointer; + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + line-height: 1; +} + +.workboard-card__move-select:hover { + border-color: var(--border-strong); + background-color: var(--bg-hover); +} + +.workboard-card__move-select:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 56%, transparent); + outline-offset: 2px; + border-color: var(--accent); +} + +.workboard-card__move-select:disabled { + cursor: not-allowed; + opacity: 0.58; +} + .workboard-card__delete:hover { border-color: color-mix(in srgb, var(--danger) 34%, var(--border)); background: color-mix(in srgb, var(--danger) 14%, transparent); diff --git a/ui/src/ui/views/workboard.test.ts b/ui/src/ui/views/workboard.test.ts index 4bb37eaed1a8..001c43b4ce2b 100644 --- a/ui/src/ui/views/workboard.test.ts +++ b/ui/src/ui/views/workboard.test.ts @@ -509,10 +509,161 @@ describe("renderWorkboard", () => { expect( container.querySelector(".workboard-toolbar__actions .btn.primary"), ).toBeNull(); + expect(container.querySelector(".workboard-card__move-select")).toBeNull(); expect(container.querySelector(".workboard-card")?.getAttribute("draggable")).toBe("false"); expect(container.querySelector(".workboard-card")?.getAttribute("role")).toBe("button"); }); + it("moves a card from the compact status control", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-1", + title: "Keyboard move", + status: "todo", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + }, + ]; + const request = vi.fn(async () => ({ + card: { ...state.cards[0], status: "blocked", position: 1000, 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); + const moveSelect = container.querySelector(".workboard-card__move-select"); + expect(moveSelect?.value).toBe("todo"); + expect(moveSelect?.tagName).toBe("SELECT"); + expect(moveSelect?.getAttribute("aria-keyshortcuts")).toBe("ArrowLeft ArrowRight"); + expect(moveSelect?.getAttribute("aria-label")).toBe("Status: Keyboard move"); + + moveSelect!.value = "blocked"; + moveSelect!.dispatchEvent(new Event("change", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + render(renderWorkboard(props), container); + + expect(request).toHaveBeenCalledWith("workboard.cards.move", { + id: "card-1", + status: "blocked", + position: 1000, + }); + const blockedColumn = [...container.querySelectorAll(".workboard-column")].find( + (column) => column.querySelector("h2")?.textContent === "Blocked", + ); + expect(blockedColumn?.textContent).toContain("Keyboard move"); + expect(state.cards[0]).toMatchObject({ status: "blocked", updatedAt: 2 }); + }); + + it("moves a focused status control with keyboard arrows", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-1", + title: "Keyboard arrow move", + status: "todo", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + }, + ]; + const request = vi.fn(async () => ({ + card: { ...state.cards[0], status: "scheduled", position: 1000, 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); + const moveSelect = container.querySelector(".workboard-card__move-select"); + const dispatched = moveSelect!.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }), + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(dispatched).toBe(false); + expect(request).toHaveBeenCalledWith("workboard.cards.move", { + id: "card-1", + status: "scheduled", + position: 1000, + }); + expect(state.cards[0]).toMatchObject({ status: "scheduled", updatedAt: 2 }); + }); + + it("does not queue status-control moves while a card is busy", async () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.busyCardId = "card-1"; + state.cards = [ + { + id: "card-1", + title: "Busy move", + status: "todo", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + }, + ]; + const request = vi.fn(); + 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); + const moveSelect = container.querySelector(".workboard-card__move-select"); + expect(moveSelect?.disabled).toBe(true); + + moveSelect!.value = "blocked"; + moveSelect!.dispatchEvent(new Event("change", { bubbles: true })); + const dispatched = moveSelect!.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }), + ); + await Promise.resolve(); + + expect(dispatched).toBe(false); + expect(request).not.toHaveBeenCalled(); + expect(state.cards[0]).toMatchObject({ status: "todo", updatedAt: 1 }); + }); + it("offers start controls when a linked session no longer exists", () => { const host = {}; const state = getWorkboardState(host); diff --git a/ui/src/ui/views/workboard.ts b/ui/src/ui/views/workboard.ts index eb9178667701..3b55f9a2c35f 100644 --- a/ui/src/ui/views/workboard.ts +++ b/ui/src/ui/views/workboard.ts @@ -299,6 +299,75 @@ function isCardActionTarget(event: Event): boolean { : false; } +function moveCardToStatus( + props: WorkboardProps, + card: WorkboardCard, + status: WorkboardStatus, + state: WorkboardUiState, +) { + if (status === card.status || state.busyCardId === card.id || !props.connected || !props.client) { + return; + } + void moveWorkboardCard({ + host: props.host, + client: props.client, + cardId: card.id, + status, + position: nextPosition(state.cards, status), + requestUpdate: props.onRequestUpdate, + }); +} + +function renderCardMoveControl(props: WorkboardProps, card: WorkboardCard, busy: boolean) { + const state = getWorkboardState(props.host); + const statuses = state.statuses.includes(card.status) + ? state.statuses + : [card.status, ...state.statuses]; + if (statuses.length < 2) { + return nothing; + } + return html` + + `; +} + function openCardDetails(state: WorkboardUiState, card: WorkboardCard) { state.detailCardId = card.id; state.detailCommentBody = ""; @@ -1223,6 +1292,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) { ${showStartControls ? renderStartExecutionButton(props, card, null, "autonomous") : nothing} ${writable ? html` + ${renderCardMoveControl(props, card, busy)}