Skip to content
Open
12 changes: 12 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
ArrowLeftIcon,
BugIcon,
ChartNoAxesColumnIcon,
KanbanIcon,
CheckIcon,
ChevronRightIcon,
CornerLeftUpIcon,
Expand Down Expand Up @@ -1676,6 +1677,17 @@ function OpenCommandPaletteDialog(props: {
},
});

actionItems.push({
kind: "action",
value: "action:board",
searchTerms: ["board", "kanban", "tasks", "status", "overview"],
title: "Open task board",
icon: <KanbanIcon className={ITEM_ICON_CLASS} />,
run: async () => {
await navigate({ to: "/board" });
},
});

for (const direction of [1, -1] as const) {
const title = direction === 1 ? "Focus Next Workspace Area" : "Focus Previous Workspace Area";
actionItems.push({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ describe("HistorySwipeNavigation", () => {
["swiping back at a shadow-root scroller's left edge navigates", "shadow", 0, -130, true],
["swiping back over a panel without overflow navigates", "none", 0, -130, true],
["a swipe that started in a closed dialog keeps the thread", "closed-dialog", 0, -130, false],
[
"swiping back at the edge of an opted-out scroller keeps the thread",
"opt-out",
0,
-130,
false,
],
] as const)("%s", async (_name, scroller, scrollLeft, deltaX, navigates) => {
navigateHistory.mockReset();
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
Expand All @@ -39,6 +46,7 @@ describe("HistorySwipeNavigation", () => {
const content = viewport.appendChild(document.createElement("div"));
content.style.cssText = `height: 20px; width: ${noOverflow ? 100 : 1000}px`;
viewport.scrollLeft = scrollLeft;
if (scroller === "opt-out") viewport.dataset.historySwipe = "off";

if (scroller === "closed-dialog") {
panel.setAttribute("role", "dialog");
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/HistorySwipeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ function lockSwipeAxis(
): Exclude<SwipeGesture, { kind: "pending" }> {
const { path, sumX, sumY } = pending;
if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" };
if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) {
// `data-history-swipe="off"` marks a horizontal scroller whose edges must not navigate.
const blocksSwipe = '[role="dialog"], [data-history-swipe="off"]';
if (path.some((node) => node instanceof Element && node.matches(blocksSwipe))) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return { kind: "ignored" };
}
if (canScrollHorizontally(path, sumX)) return { kind: "ignored" };
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,7 +800,9 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
}

/** Unopened threads' failures still count as unseen, unlike completions. */
function hasUnseenFailure(thread: ThreadStatusInput): boolean {
export function hasUnseenFailure(
thread: Pick<ThreadStatusInput, "latestRun" | "lastVisitedAt">,
): boolean {
if (thread.latestRun?.status !== "failed") return false;
if (!thread.lastVisitedAt) return true;
const completedAt = Date.parse(thread.latestRun.completedAt ?? "");
Expand Down
33 changes: 27 additions & 6 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ import {
type SidebarSweepAction,
} from "./sidebar/SidebarThreadRow";
import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock";
import {
type ThreadContextMenuOptions,
useProvideThreadContextMenu,
} from "./sidebar/threadContextMenu";
import {
SidebarProjectFolderHeader,
sidebarFolderRowId,
Expand Down Expand Up @@ -2590,17 +2594,29 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
);

const handleThreadContextMenu = useStableCallback(
(threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
(
threadRef: ScopedThreadRef,
position: { x: number; y: number },
options?: ThreadContextMenuOptions,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
) => {
void (async () => {
const api = readLocalApi();
if (!api) return;
const threadKey = scopedThreadKey(threadRef);
const selectionState = useThreadSelectionStore.getState();
if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) {
// Only sidebar rows act on the sidebar's selection; other surfaces pass options.
if (
options === undefined &&
selectionState.hasSelection() &&
selectionState.selectedThreadKeys.has(threadKey)
) {
await handleMultiSelectContextMenu(position);
return;
}
const thread = threadByKeyRef.current.get(threadKey);
// The task board also opens this menu for threads the list is not showing.
const thread =
threadByKeyRef.current.get(threadKey) ??
threads.find((candidate) => threadShellKey(candidate) === threadKey);
if (!thread) return;
const threadWorkspacePath =
thread.worktreePath ??
Expand All @@ -2611,8 +2627,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
serverConfigs.get(thread.environmentId)?.environment.capabilities
.threadAutoSettleOptOut === true;
const isRegeneratingTitle = thread.titleRegeneration != null;
const isSettled = settledThreadKeysRef.current.has(threadKey);
const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
const isSettled =
settledThreadKeysRef.current.has(threadKey) ||
(configLoaded && thread.settledOverride === "settled");
const isSnoozed =
snoozedThreadKeysRef.current.has(threadKey) ||
(configLoaded && effectiveSnoozed(thread, { now: new Date().toISOString() }));
const isPinned = thread.pinnedAt != null;
const snoozePresets = resolveSnoozePresets(
new Date(),
Expand Down Expand Up @@ -2773,7 +2793,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
return;
}
case "rename":
startThreadRename(threadRef, thread.title);
(options?.onStartRename ?? startThreadRename)(threadRef, thread.title);
return;
case "share-guest":
openGuestShareDialog(threadRef);
Expand Down Expand Up @@ -2886,6 +2906,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
})();
},
);
useProvideThreadContextMenu(handleThreadContextMenu);

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown for thread navigation shortcuts using the current thread list
useEffect(() => {
Expand Down
233 changes: 233 additions & 0 deletions apps/web/src/components/board/TaskBoardPage.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import { render } from "../../../test/render";
import {
type ThreadContextMenuOptions,
useProvideThreadContextMenu,
} from "../sidebar/threadContextMenu";
import { TaskBoardPage } from "./TaskBoardPage";

const fixture = vi.hoisted(() => ({
updateMetadata: vi.fn(async (_request: unknown) => ({ _tag: "Success" as const })),
}));

function shell(id: string, title: string, overrides: Record<string, unknown> = {}) {
return {
id,
environmentId: "environment-one",
projectId: "project-one",
title,
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
archivedAt: null,
settledOverride: null,
pinnedAt: null,
updatedAt: "2026-01-01T10:00:00.000Z",
hasPendingApprovals: false,
hasPendingUserInput: false,
runtime: null,
snoozedUntil: null,
...overrides,
};
}

vi.mock("../../state/entities", async (original) => {
const threads = [
shell("thread-done", "Finished work"),
shell("thread-approval", "Needs approval", { hasPendingApprovals: true }),
];
return {
...(await original<object>()),
useVisibleThreadShells: () => threads,
useProjects: () => [],
};
});
vi.mock("../../state/session", async (original) => ({
...(await original<object>()),
readEnvironmentScope: () => true,
}));
vi.mock("../../state/use-orchestration-command", () => ({
useOrchestrationCommand: () => fixture.updateMetadata,
}));

type MenuCall = [ScopedThreadRef, { x: number; y: number }, ThreadContextMenuOptions | undefined];

function SidebarMenuStub(props: { onMenu: (...args: MenuCall) => void }) {
useProvideThreadContextMenu(props.onMenu);
return null;
}

function pointer(type: string, x: number, y: number) {
return new PointerEvent(type, {
bubbles: true,
cancelable: true,
isPrimary: true,
button: 0,
buttons: type === "pointerup" ? 0 : 1,
clientX: x,
clientY: y,
});
}

const panel = (label: string) => page.getByRole("region", { name: label }).element();
const column = (label: string) => panel(label).closest<HTMLElement>("[data-board-column]");
const grow = (label: string) => Number(panel(label).parentElement!.style.flexGrow);

describe("TaskBoardPage", () => {
it("opens the sidebar thread menu on right-click and renames in place", async () => {
localStorage.clear();
fixture.updateMetadata.mockClear();
const onMenu = vi.fn<(...args: MenuCall) => void>();
await using screen = await render(
<>
<SidebarMenuStub onMenu={onMenu} />
<TaskBoardPage />
</>,
);

await userEvent.click(screen.getByRole("button", { name: /Finished work/ }), {
button: "right",
});
expect(onMenu).toHaveBeenCalledOnce();
const [threadRef, , options] = onMenu.mock.calls[0]!;
expect(threadRef).toEqual({ environmentId: "environment-one", threadId: "thread-done" });

options?.onStartRename?.(threadRef, "Finished work");
const input = screen.getByRole("textbox", { name: "Thread title" });
await userEvent.fill(input, "Renamed work");
await userEvent.keyboard("{Enter}");
expect(fixture.updateMetadata).toHaveBeenCalledWith({
environmentId: "environment-one",
input: { threadId: "thread-done", title: "Renamed work" },
});
});

it("collapses, stacks and keeps the layout across remounts", async () => {
localStorage.clear();
{
await using screen = await render(<TaskBoardPage />);
await userEvent.click(screen.getByRole("button", { name: "Collapse Failed" }));
await expect.element(screen.getByRole("button", { name: "Expand Failed" })).toBeVisible();

const header = panel("Working").querySelector("h2")!;
const from = header.getBoundingClientRect();
const target = panel("Needs you").getBoundingClientRect();
const drop = { x: target.left + target.width / 2, y: target.bottom - 10 };
header.dispatchEvent(pointer("pointerdown", from.left + 10, from.top + 5));
document.dispatchEvent(pointer("pointermove", from.left + 30, from.top + 5));
await vi.waitFor(() => expect(panel("Working").className).toContain("opacity-50"));
document.dispatchEvent(pointer("pointermove", drop.x, drop.y));
await vi.waitFor(() =>
expect(panel("Needs you").querySelector(".bg-primary")).not.toBeNull(),
);
document.dispatchEvent(pointer("pointerup", drop.x, drop.y));
await vi.waitFor(() => expect(column("Working")).toBe(column("Needs you")));
}

await using screen = await render(<TaskBoardPage />);
await expect.element(screen.getByRole("button", { name: "Expand Failed" })).toBeVisible();
const stack = column("Needs you")!;
expect([...stack.querySelectorAll("section")].map((section) => section.ariaLabel)).toEqual([
"Needs you",
"Working",
]);
});

it("stacks a panel dropped by a single quick move", async () => {
localStorage.clear();
await using _screen = await render(<TaskBoardPage />);
const header = panel("Working").querySelector("h2")!;
const from = header.getBoundingClientRect();
const target = panel("Needs you").getBoundingClientRect();
const drop = { x: target.left + target.width / 2, y: target.bottom - 10 };
header.dispatchEvent(pointer("pointerdown", from.left + 10, from.top + 5));
// The only move both activates the drag and lands on the target.
document.dispatchEvent(pointer("pointermove", drop.x, drop.y));
await vi.waitFor(() => expect(panel("Working").className).toContain("opacity-50"));
document.dispatchEvent(pointer("pointerup", drop.x, drop.y));
await vi.waitFor(() => expect(column("Working")).toBe(column("Needs you")));
});

it("moves the focused panel with Alt+arrows and keeps focus on it", async () => {
localStorage.clear();
await using screen = await render(<TaskBoardPage />);
const toggle = () => screen.getByRole("button", { name: "Collapse Working" }).element();
(toggle() as HTMLElement).focus();
await userEvent.keyboard("{Alt>}{Shift>}{ArrowLeft}{/Shift}{/Alt}");
await vi.waitFor(() => {
expect(column("Working")).toBe(column("Needs you"));
expect(document.activeElement).toBe(toggle());
});
await userEvent.keyboard("{Alt>}{ArrowUp}{/Alt}");
await vi.waitFor(() =>
expect(
[...column("Working")!.querySelectorAll("section")].map((section) => section.ariaLabel),
).toEqual(["Working", "Needs you"]),
);
await userEvent.keyboard("{Alt>}{ArrowRight}{/Alt}");
await vi.waitFor(() => {
expect(column("Working")).not.toBe(column("Needs you"));
expect(document.activeElement).toBe(toggle());
});
});

it("resizes stacked panels, keeps the split across remounts, and resets on double-click", async () => {
localStorage.clear();
// A layout saved before weights existed loads as equal shares.
localStorage.setItem(
"t3code:task-board:layout",
JSON.stringify({ columns: [["attention", "done"]], collapsed: [] }),
);
const separator = { name: "Resize Needs you and Done" };
{
await using screen = await render(<TaskBoardPage />);
const handle = screen.getByRole("separator", separator).element() as HTMLElement;
expect([grow("Needs you"), grow("Done")]).toEqual([1, 1]);
// Synthetic drag events have no active browser pointer to capture.
using _capture = vi.spyOn(handle, "setPointerCapture").mockImplementation(() => {});
const pointer = { bubbles: true, pointerId: 1, button: 0, clientX: 0 };
handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientY: 300 }));
handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientY: 360 }));
expect(panel("Needs you").className).not.toContain("opacity-50");
await vi.waitFor(() =>
expect(localStorage.getItem("t3code:task-board:layout")).toContain("weights"),
);
expect(grow("Needs you")).toBeGreaterThan(grow("Done"));
expect(grow("Needs you") + grow("Done")).toBeCloseTo(2);
}

await using screen = await render(<TaskBoardPage />);
expect(grow("Needs you")).toBeGreaterThan(grow("Done"));
// Unstyled in tests, the handle has no box for a real pointer to hit.
const handle = screen.getByRole("separator", separator).element();
handle.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await vi.waitFor(() => expect([grow("Needs you"), grow("Done")]).toEqual([1, 1]));
});

it("resizes a column by its edge, keeps the width across remounts, and resets on double-click", async () => {
localStorage.clear();
const separator = { name: "Resize Needs you column" };
const width = () => column("Needs you")!.style.width;
{
await using screen = await render(<TaskBoardPage />);
const handle = screen.getByRole("separator", separator).element() as HTMLElement;
expect(width()).toBe("288px");
// Synthetic drag events have no active browser pointer to capture.
using _capture = vi.spyOn(handle, "setPointerCapture").mockImplementation(() => {});
const pointer = { bubbles: true, pointerId: 1, button: 0, clientY: 0 };
handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 300 }));
handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 400 }));
await vi.waitFor(() =>
expect(localStorage.getItem("t3code:task-board:layout")).toContain("widths"),
);
expect(width()).toBe("388px");
}

await using screen = await render(<TaskBoardPage />);
expect(width()).toBe("388px");
const handle = screen.getByRole("separator", separator).element();
handle.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await vi.waitFor(() => expect(width()).toBe("288px"));
});
});
Loading
Loading