Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@
@import "@corbits/tasks-ui/styles.css";
@import "@corbits/plugins-ui/styles.css";

/* react-ui's `.corbits-toast` ships a hardcoded horizontal-center transform
(`left: 50%; transform: translateX(-50%) !important`) regardless of the
`position` prop passed to `<Toaster>` — main.tsx now mounts it
bottom-right, and without this the toast would still visually sit
centered while claiming to be on the right. Sonner itself already
right-aligns the toast via its own `[data-x-position="right"]` rule
(injected at runtime, unrelated to this stylesheet); this just stops
react-ui's override from fighting it. Higher specificity than that rule
wins the cascade on its own, so this is safe regardless of load order. */
[data-sonner-toaster][data-x-position="right"]
[data-sonner-toast].corbits-toast {
left: auto !important;
transform: none !important;
}

/* Mock chrome: 15px root so rem-based density matches the interactive mock. */
html {
font-size: 15px;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ function Root() {
provisioningErrorRefId={provisioningError?.refId}
onRetryProvisioning={handleRetryProvisioning}
/>
<Toaster />
<Toaster position="bottom-right" />
</ThemeProvider>
);
}
Expand Down
33 changes: 27 additions & 6 deletions apps/web/src/pages/library-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ function PreviewPane({
? artifactPreviewPath(tenantId, detail.id)
: undefined;
return (
<aside className="flex min-h-0 min-w-0 flex-col border-l border-border bg-card">
<aside className="flex h-full min-h-0 min-w-0 flex-col border-l border-border bg-card">
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
Expand Down Expand Up @@ -443,15 +443,24 @@ export function LibraryPage({
actions={
<>
{selectedSummary !== null ? (
<Button variant="outline" size="sm" onClick={() => select(null)}>
All
// This clears the open file and returns to the list — an
// action, not a filter. It used to say bare "All", which read
// as a third option in the scope group right beside it ("All"
// vs. "All workbenches"); this label can't be mistaken for
// that.
<Button variant="ghost" size="sm" onClick={() => select(null)}>
Back to files
</Button>
) : null}
{workbenchScope !== null && onScopeChange !== undefined ? (
// One control, two states — answers exactly one question
// ("whose files"), styled as the bordered segmented group
// `ViewToggle` already uses for rows/grid in this same bar, so
// it reads as one control rather than two stray chips.
<div
role="group"
aria-label="Files scope"
className="hidden items-center gap-1 lg:flex"
className="hidden items-center gap-0.5 rounded-md border border-border p-0.5 lg:flex"
>
<Button
type="button"
Expand Down Expand Up @@ -770,11 +779,23 @@ export function LibraryRoute({ path }: { readonly path: string }) {
setUploading(true);
setUploadError(null);
try {
await uploadArtifactFiles(selectedTenantId, files);
const uploaded = await uploadArtifactFiles(
selectedTenantId,
files,
);
await queryClient.invalidateQueries({
queryKey: tenantKeys.artifacts(selectedTenantId),
});
toast(artifactUploadToast(files.map((file) => file.name)));
// The confirmation names what the server actually stored
// (its own titles), never the local `File` picked — the
// two can differ (e.g. a collision rename), and a sibling
// fix for empty content read-back means this toast must
// only ever repeat the upload response, not assume it.
toast(
artifactUploadToast(
uploaded.map((artifact) => artifact.title),
),
);
} catch (err) {
setUploadError(
describeApiError(err, "uploading those files"),
Expand Down
50 changes: 50 additions & 0 deletions apps/web/test/library-page-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,54 @@ describe("LibraryPage top-nav action placement", () => {
});
expect(container.querySelector('[data-slot="table"]')).not.toBeNull();
});

test("the workbench-scope pair reads as one grouped control, distinct from the clear-selection action", () => {
act(() => {
root.render(
<LibraryPage
artifacts={artifacts}
selectedId="art_1"
onSelect={() => undefined}
workbenchScope={{ title: "Launch plan" }}
scope="all"
onScopeChange={() => undefined}
/>,
);
});
const topBarActions = container.querySelector(
'[data-testid="stage-top-bar-actions"]',
);
// The two-state scope toggle is visually one control (a bordered
// segmented group, the same idiom `ViewToggle` already uses in this
// bar) rather than two stray buttons that read as independent chips.
const scopeGroup = topBarActions?.querySelector(
'[aria-label="Files scope"]',
);
expect(scopeGroup?.className).toContain("border");
expect(scopeGroup?.textContent).toContain("Launch plan");
expect(scopeGroup?.textContent).toContain("All workbenches");

// The clear-selection action is not a filter and must not read as one:
// no button labelled bare "All" sits beside "All workbenches".
const buttons = [...(topBarActions?.querySelectorAll("button") ?? [])];
expect(buttons.some((b) => b.textContent?.trim() === "All")).toBe(false);
expect(buttons.some((b) => b.textContent?.trim() === "Back to files")).toBe(
true,
);
});

test("the file detail pane fills the available height", () => {
act(() => {
root.render(
<LibraryPage
artifacts={artifacts}
selectedId="art_1"
onSelect={() => undefined}
/>,
);
});
const pane = container.querySelector("aside");
expect(pane).not.toBeNull();
expect(pane?.className).toContain("h-full");
});
});
169 changes: 169 additions & 0 deletions apps/web/test/library-upload-confirmation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// The owner reported uploading a file with no way to tell it worked. The
// fix must be an honest confirmation: it names the file the server actually
// stored, not the one the browser happened to send, and it stays silent
// (surfacing the failure instead) when the upload endpoint reports one.
// A sibling lane is fixing a real bug where uploaded content reads back
// empty — this suite only pins that the toast is grounded in the upload
// response, never a blind echo of the local `File` picked.

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";

import { BenchProvider } from "../src/bench-context";
import { NavigationProvider } from "../src/navigation";
import { LibraryRoute } from "../src/pages/library-page";
import { spyOnReactUiToast } from "./react-ui-toast-mock";
import { TestQueryProvider } from "./test-query-provider";

const toastMock = spyOnReactUiToast();
const noop = () => undefined;
const realFetch = globalThis.fetch;

const membership = {
principalId: "prn_1",
tenantId: "tnt_1",
tenantName: "Test Bench",
tenantSlug: "test-bench",
kind: "user",
status: "active",
roles: [],
};

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}

function uploadFile(container: HTMLDivElement, file: File): void {
const input = container.querySelector(
'input[aria-label="Upload files"]',
) as HTMLInputElement | null;
if (input === null) throw new Error("no upload input");
Object.defineProperty(input, "files", {
configurable: true,
value: [file],
});
act(() => {
input.dispatchEvent(new Event("change", { bubbles: true }));
});
}

describe("Files upload confirmation", () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
toastMock.mockClear();
});

afterEach(() => {
act(() => root.unmount());
container.remove();
globalThis.fetch = realFetch;
window.localStorage.clear();
});

async function settle(until: () => boolean): Promise<void> {
for (let i = 0; i < 30; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
if (until()) return;
}
}

function renderRoute(): void {
act(() => {
root.render(
<TestQueryProvider>
<NavigationProvider navigate={noop}>
<BenchProvider>
<LibraryRoute path="/files" />
</BenchProvider>
</NavigationProvider>
</TestQueryProvider>,
);
});
}

test("a completed upload confirms the title the server stored, not the local file name", async () => {
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/me/principals")) {
return Promise.resolve(
jsonResponse({ data: [membership], nextCursor: null }),
);
}
if (url.includes("/artifacts/upload") && init?.method === "POST") {
return Promise.resolve(
jsonResponse({
data: [
{
id: "art_new",
kind: "document",
// The server renamed it on collision — the confirmation
// must say this, not the "draft.txt" the browser sent.
title: "draft (1).txt",
source: {},
version: 1,
ownerPrincipalId: null,
ownerName: null,
content: "",
archivedAt: null,
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-01T00:00:00.000Z",
},
],
}),
);
}
if (url.includes("/artifacts")) {
return Promise.resolve(jsonResponse({ data: [], nextCursor: null }));
}
return Promise.reject(new Error(`unrouted fetch: ${url}`));
}) as typeof fetch;

renderRoute();
await settle(() => container.querySelector('input[type="file"]') !== null);

uploadFile(container, new File(["hi"], "draft.txt"));
await settle(() => toastMock.mock.calls.length > 0);

expect(toastMock).toHaveBeenCalledWith("Uploaded · draft (1).txt");
expect(container.textContent).not.toContain("draft.txt");
});

test("a failed upload surfaces the failure instead of a fake success toast", async () => {
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/me/principals")) {
return Promise.resolve(
jsonResponse({ data: [membership], nextCursor: null }),
);
}
if (url.includes("/artifacts/upload") && init?.method === "POST") {
return Promise.resolve(jsonResponse({ error: "boom" }, 500));
}
if (url.includes("/artifacts")) {
return Promise.resolve(jsonResponse({ data: [], nextCursor: null }));
}
return Promise.reject(new Error(`unrouted fetch: ${url}`));
}) as typeof fetch;

renderRoute();
await settle(() => container.querySelector('input[type="file"]') !== null);

uploadFile(container, new File(["hi"], "draft.txt"));
await settle(() => container.querySelector('[role="alert"]') !== null);

expect(toastMock).not.toHaveBeenCalled();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toBeTruthy();
});
});
32 changes: 32 additions & 0 deletions apps/web/test/toast-single-system.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,36 @@ describe("the one toast system (CL-6372)", () => {
});
expect(visibleToasts().length).toBe(0);
});

// The owner's earlier screenshots showed the toast bottom-center, easy to
// miss against page content. main.tsx (the app's one `<Toaster>` mount)
// now passes `position="bottom-right"` — asserted here the same way the
// house-styling test above asserts the default, so a regression back to
// center fails this instead of only showing up in a screenshot.
test("the app's Toaster mounts bottom-right, not the library default bottom-center", async () => {
stubFailingCreate();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<TestQueryProvider>
<NavigationProvider navigate={() => undefined}>
<BenchProvider>
<NewWorkbenchPickerRoute />
</BenchProvider>
</NavigationProvider>
<Toaster position="bottom-right" />
</TestQueryProvider>,
);
});

act(() => toast("Uploaded · draft.txt"));
await settle();

const region = document.body.querySelector("[data-sonner-toaster]");
expect(region?.getAttribute("data-y-position")).toBe("bottom");
expect(region?.getAttribute("data-x-position")).toBe("right");
await waitForClear();
});
});
Loading