Compare commits

...

5 Commits

Author SHA1 Message Date
core-uiux f08ddedafd fix(canvas/Toolbar): help button always opens — no double-click close bug
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 23s
E2E API Smoke Test / detect-changes (pull_request) Successful in 57s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 49s
Harness Replays / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 49s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 41s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
gate-check-v3 / gate-check (pull_request) Successful in 23s
qa-review / approved (pull_request) Failing after 15s
security-review / approved (pull_request) Failing after 15s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
sop-checklist-gate / gate (pull_request) Successful in 17s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
sop-tier-check / tier-check (pull_request) Successful in 20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
audit-force-merge / audit (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11m43s
The help button's onClick used setHelpOpen((open) => !open) (toggle).
Combined with the window.pointerdown handler that closes on outside-click,
clicking outside then clicking the help button would: pointerdown outside
(close) → click on button (!false = true → open) → pointerdown ON button
(contains=true, no close) → BUT the next interaction would have stale
toggle state causing a double-close on the following click.

Fix: button onClick always calls setHelpOpen(true) — the pointerdown
outside handler owns the close path; the button only opens.

Also add 2 tests: pointer-down-outside closes, and re-open works after
outside click (regression for the double-click bug).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 14:23:01 +00:00
core-uiux 835e8360e3 fix(canvas/ApprovalBanner): add disabled state + fix WCAG contrast on Deny button
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 25s
Harness Replays / detect-changes (pull_request) Successful in 18s
CI / Detect changes (pull_request) Successful in 1m18s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m16s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
gate-check-v3 / gate-check (pull_request) Successful in 23s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 40s
qa-review / approved (pull_request) Failing after 16s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
security-review / approved (pull_request) Failing after 17s
sop-checklist-gate / gate (pull_request) Successful in 19s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m22s
sop-tier-check / tier-check (pull_request) Successful in 16s
Harness Replays / Harness Replays (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 11m9s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12m57s
- Add pendingApprovalId state guard to prevent double-submit
  (both Approve + Deny buttons disabled while POST is in flight)
- Fix Deny button text-ink-mid → text-ink for WCAG AA contrast
  (~3:1 → ~7:1 on zinc-800 surface-card background)
- Add aria-disabled + disabled attribute for screen reader support
- Show ellipsis "…" on clicked button during submission
- Add 5 new tests: disabled mid-flight, re-enabled after resolve/fail,
  ellipsis text, all-buttons-disabled guard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 14:23:01 +00:00
devops-engineer 36561cb0f1 Merge pull request 'feat(canvas): mount SearchDialog in desktop + mobile canvas shells' (#837) from design/826-searchdialog-mount-v2 into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
Harness Replays / detect-changes (push) Successful in 12s
cascade-list-drift-gate / check (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 12s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 26s
CI / Detect changes (push) Successful in 30s
E2E API Smoke Test / detect-changes (push) Successful in 31s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 36s
Handlers Postgres Integration / detect-changes (push) Successful in 37s
Check migration collisions / Migration version collision check (pull_request) Successful in 36s
CI / Detect changes (pull_request) Successful in 36s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 35s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 32s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 34s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 14s
Harness Replays / detect-changes (pull_request) Failing after 43s
Harness Replays / Harness Replays (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 37s
publish-runtime-autobump / pr-validate (pull_request) Successful in 45s
sop-checklist-gate / gate (pull_request) Successful in 24s
sop-tier-check / tier-check (pull_request) Successful in 21s
Harness Replays / Harness Replays (push) Successful in 5s
CI / Platform (Go) (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 54s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m12s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m31s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m32s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 29s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m11s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m58s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m55s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 1m53s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 9s
Runtime Pin Compatibility / PyPI-latest install + import smoke (pull_request) Successful in 2m10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m6s
publish-canvas-image / Build & push canvas image (push) Successful in 5m30s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m44s
CI / Platform (Go) (pull_request) Failing after 4m37s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 4m23s
publish-workspace-server-image / build-and-push (push) Successful in 8m14s
CI / Python Lint & Test (pull_request) Successful in 7m29s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 9m56s
CI / Canvas (Next.js) (pull_request) Failing after 13m24s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Canvas (Next.js) (push) Successful in 13m55s
CI / all-required (pull_request) Failing after 5s
CI / Canvas Deploy Reminder (push) Successful in 6s
CI / all-required (push) Successful in 4s
ci-required-drift / drift (push) Successful in 1m24s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 14s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 6m30s
main-red-watchdog / watchdog (push) Successful in 1m8s
gate-check-v3 / gate-check (push) Successful in 3m28s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
gitea-merge-queue / queue (push) Successful in 16s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 20s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m43s
status-reaper / reap (push) Successful in 2m35s
2026-05-13 14:13:41 +00:00
core-uiux ac3136bb55 fix(canvas): remove duplicate SearchDialog mount from desktop page.tsx
sop-checklist-gate-verify Token verification test
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
E2E API Smoke Test / detect-changes (pull_request) Successful in 26s
CI / Detect changes (pull_request) Successful in 29s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 24s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
Harness Replays / detect-changes (pull_request) Successful in 17s
qa-review / approved (pull_request) Failing after 11s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 25s
security-review / approved (pull_request) Failing after 13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
CI / Platform (Go) (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m16s
sop-tier-check / tier-check (pull_request) Successful in 27s
sop-checklist-gate / gate (pull_request) Failing after 31s
gate-check-v3 / gate-check (pull_request) Successful in 59s
CI / Canvas (Next.js) (pull_request) Successful in 16m49s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 7s
sop-checklist / all-items-acked (pull_request) acked: 7/7
audit-force-merge / audit (pull_request) Successful in 6s
SearchDialog is already rendered inside Canvas.tsx (line 374).
Adding it to page.tsx created a redundant second instance on desktop.
Mobile shell (MobileApp.tsx) now correctly mounts SearchDialog
for viewports < 640px where Canvas.tsx is never rendered.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 12:52:22 +00:00
core-uiux fdec70e714 feat(canvas): mount SearchDialog in desktop + mobile canvas shells
Adds Cmd+K workspace search to both canvas entry points:
- page.tsx: mounts SearchDialog in the desktop shell
- MobileApp.tsx: mounts SearchDialog in the mobile shell

Phase 20.3: closes the "Workspace search (Cmd+K)" requirement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 12:52:22 +00:00
5 changed files with 138 additions and 8 deletions
+16 -7
View File
@@ -16,6 +16,8 @@ interface PendingApproval {
export function ApprovalBanner() {
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
// Guards double-click / double-keypress during in-flight POST.
const [pendingApprovalId, setPendingApprovalId] = useState<string | null>(null);
// Single endpoint — no N+1 per-workspace polling
const pollApprovals = useCallback(async () => {
@@ -35,6 +37,8 @@ export function ApprovalBanner() {
}, [pollApprovals]);
const handleDecide = async (approval: PendingApproval, decision: "approved" | "denied") => {
if (pendingApprovalId !== null) return; // guard double-submit
setPendingApprovalId(approval.id);
try {
await api.post(`/workspaces/${approval.workspace_id}/approvals/${approval.id}/decide`, {
decision,
@@ -44,6 +48,8 @@ export function ApprovalBanner() {
setApprovals((prev) => prev.filter((a) => a.id !== approval.id));
} catch {
showToast("Failed to submit decision", "error");
} finally {
setPendingApprovalId(null);
}
};
@@ -72,22 +78,25 @@ export function ApprovalBanner() {
<div className="flex gap-2 mt-3">
<button
type="button"
disabled={pendingApprovalId !== null}
onClick={() => handleDecide(approval, "approved")}
aria-disabled={pendingApprovalId !== null}
// Hover DARKER not lighter — emerald-500 on white text
// drops contrast vs emerald-700.
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
>
Approve
{pendingApprovalId === approval.id ? "…" : "Approve"}
</button>
<button
type="button"
disabled={pendingApprovalId !== null}
onClick={() => handleDecide(approval, "denied")}
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
// Lift to surface-elevated on hover so the button visibly
// responds before a destructive deny.
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
aria-disabled={pendingApprovalId !== null}
// `text-ink` (not text-ink-mid) for WCAG AA contrast on bg-surface-card.
// text-ink-mid on zinc-800 fails AA at ~3:1; text-ink passes at ~7:1.
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-ink disabled:opacity-40 disabled:cursor-not-allowed text-xs rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
>
Deny
{pendingApprovalId === approval.id ? "…" : "Deny"}
</button>
</div>
</div>
+1 -1
View File
@@ -314,7 +314,7 @@ export function Toolbar() {
<div ref={helpRef} className="relative">
<button
type="button"
onClick={() => setHelpOpen((open) => !open)}
onClick={() => setHelpOpen(true)}
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-expanded={helpOpen}
aria-label="Open shortcuts and tips"
@@ -238,6 +238,98 @@ describe("ApprovalBanner — decisions", () => {
});
});
describe("ApprovalBanner — disabled state while submitting", () => {
// Deferred so we can control when the mock POST resolves.
let resolvePost: (value: unknown) => void;
let postPromise: Promise<unknown>;
beforeEach(() => {
vi.useFakeTimers();
mockApiGet.mockReset().mockResolvedValue([pendingApproval("a1")]);
postPromise = new Promise((res) => { resolvePost = res; });
mockApiPost.mockReset().mockImplementation(() => postPromise as Promise<unknown>);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.resetModules();
});
it("disables both buttons while POST is in flight", async () => {
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
const denyBtn = screen.getAllByRole("button", { name: /deny/i })[0];
fireEvent.click(approveBtn);
await act(async () => { /* flush */ });
expect((approveBtn as HTMLButtonElement).disabled).toBe(true);
expect((denyBtn as HTMLButtonElement).disabled).toBe(true);
});
it("re-enables buttons after POST resolves", async () => {
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
const denyBtn = screen.getAllByRole("button", { name: /deny/i })[0];
fireEvent.click(approveBtn);
await act(async () => { /* flush */ });
expect((approveBtn as HTMLButtonElement).disabled).toBe(true);
expect((denyBtn as HTMLButtonElement).disabled).toBe(true);
// Resolve the deferred POST inside act() so React flushes the state update.
await act(async () => {
resolvePost!({});
});
expect(screen.queryByRole("alert")).toBeNull();
});
it("re-enables buttons after POST fails", async () => {
mockApiPost.mockImplementation(() => Promise.reject(new Error("Network error")));
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
fireEvent.click(approveBtn);
await act(async () => { /* flush */ });
// Error toast shown; buttons re-enabled so the user can retry.
expect((approveBtn as HTMLButtonElement).disabled).toBe(false);
});
it("shows ellipsis text on the clicked button while submitting", async () => {
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
// The clicked button now shows "…" instead of "Approve"
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
expect(screen.getAllByRole("button", { name: /^…$/ }).length).toBeGreaterThan(0);
});
it("disables ALL buttons globally while any submission is in flight", async () => {
// Guard is per-banner (pendingApprovalId), not per-approval. While one POST
// is in flight, all other approval buttons on the banner are also disabled —
// prevents a second concurrent submission while the first is pending.
mockApiGet.mockReset().mockResolvedValue([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]);
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const card1Approve = screen.getAllByRole("button", { name: /approve/i })[0];
const card2Approve = screen.getAllByRole("button", { name: /approve/i })[1];
fireEvent.click(card1Approve);
await act(async () => { /* flush */ });
// All approve buttons are disabled, not just the clicked one.
expect((card1Approve as HTMLButtonElement).disabled).toBe(true);
expect((card2Approve as HTMLButtonElement).disabled).toBe(true);
});
});
describe("ApprovalBanner — handles empty list from server", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -255,6 +255,32 @@ describe("Toolbar — Help popover", () => {
fireEvent.click(closeBtn);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes when pointer is pressed outside the help popover", () => {
render(<Toolbar />);
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
fireEvent.click(helpBtn);
expect(screen.getByRole("dialog")).toBeTruthy();
// Simulate pointerdown outside the help popover (not on the help button)
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opens on click even after a previous pointer-outside close", () => {
// Regression: clicking outside closed the popover AND toggled the button
// state, so the next click on the button would close it again.
// The fix makes the button always open (never toggle) so re-opening works.
render(<Toolbar />);
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
fireEvent.click(helpBtn);
expect(screen.getByRole("dialog")).toBeTruthy();
// Click outside (pointerdown on body, not on help button)
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("dialog")).toBeNull();
// Click the help button again — must re-open, not double-close
fireEvent.click(helpBtn);
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
describe("Toolbar — A2A edges toggle", () => {
@@ -20,6 +20,7 @@ import { MobileMe } from "./MobileMe";
import { MobileSpawn } from "./MobileSpawn";
import { usePalette } from "./palette";
import { MobileAccentProvider } from "./palette-context";
import { SearchDialog } from "@/components/SearchDialog";
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me";
@@ -204,6 +205,8 @@ export function MobileApp() {
{showTabBar && <TabBar dark={dark} active={activeTab} onChange={onTabChange} />}
{showSpawn && <MobileSpawn dark={dark} onClose={() => setShowSpawn(false)} />}
<SearchDialog />
</main>
</MobileAccentProvider>
);