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
51 changes: 51 additions & 0 deletions src/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ beforeEach(() => {
vi.mocked(api.getTask).mockResolvedValue({ task: task({ title: "Detailed task" }) });
vi.mocked(api.settings).mockResolvedValue(settings());
vi.mocked(api.providerStatus).mockResolvedValue({ providers: [providerStatus()] });
vi.mocked(api.openAiAuth).mockResolvedValue({ configured: false, login: { phase: "idle" } });
vi.mocked(api.openAiAccountInfo).mockResolvedValue({
configured: false,
prefetchedAt: "2026-05-02T00:00:00.000Z",
recommendedModel: "gpt-5.5",
currentModel: "gpt-5.5",
currentModelSupported: true,
models: [],
});
vi.mocked(api.startOpenAiAuth).mockResolvedValue({ configured: false, login: { phase: "idle" } });
vi.mocked(api.submitOpenAiAuthInput).mockResolvedValue({
configured: false,
login: { phase: "idle" },
});
vi.mocked(api.logoutOpenAiAuth).mockResolvedValue({
configured: false,
login: { phase: "idle" },
});
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
Expand Down Expand Up @@ -104,6 +122,29 @@ describe("App task detail routing", () => {
});
});

describe("App settings view", () => {
it("hides board actions and uses settings-specific copy", async () => {
await renderApp();

await waitFor(() =>
expect(
document.body.querySelector('[role="button"][aria-label="Open task Route task"]'),
).toBeTruthy(),
);
await clickButtonByText("Settings");

await waitFor(() => {
expect(document.body.textContent).toContain(
"Connect OpenAI and manage local board preferences.",
);
expect(document.body.textContent).toContain("OpenAI connection");
});

expect(document.body.querySelector('[aria-label="Search tasks"]')).toBeNull();
expect(document.body.querySelector('button[aria-label="New task"]')).toBeNull();
});
});

async function renderApp() {
await act(async () => {
root.render(<App />);
Expand All @@ -118,6 +159,16 @@ async function clickSelector(selector: string) {
});
}

async function clickButtonByText(text: string) {
const button = Array.from(document.body.querySelectorAll("button")).find((entry) =>
entry.textContent?.includes(text),
);
expect(button).toBeTruthy();
await act(async () => {
button!.click();
});
}

async function waitFor(assertion: () => void) {
let lastError: unknown;
for (let index = 0; index < 120; index += 1) {
Expand Down
9 changes: 5 additions & 4 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const VIEW_META: Record<View, { title: string; description: string }> = {
},
settings: {
title: "Settings",
description: "Manage focus areas for your local board.",
description: "Connect OpenAI and manage local board preferences.",
},
};

Expand Down Expand Up @@ -543,6 +543,7 @@ export function App() {
() => getVisibleStatuses(boardScope, filteredTasks),
[boardScope, filteredTasks],
);
const showBoardHeaderActions = workspaceMode === "board" && view === "board";

useEffect(() => {
if (activeFocusAreaId && !focusAreas.some((area) => area.id === activeFocusAreaId)) {
Expand Down Expand Up @@ -597,7 +598,7 @@ export function App() {
<h1 className="truncate text-sm font-medium sm:text-base">
{workspaceMode === "chat" ? "Chat" : activeView.title}
</h1>
{workspaceMode === "board" && (
{showBoardHeaderActions && (
<Badge variant="secondary" className="hidden sm:inline-flex">
<Monitor data-icon="inline-start" />
{openAiReady ? "OpenAI ready" : "Local board"}
Expand All @@ -611,7 +612,7 @@ export function App() {
</p>
</div>
</div>
{workspaceMode === "board" && (
{showBoardHeaderActions && (
<>
<InputGroup className="hidden max-w-sm xl:flex">
<InputGroupAddon>
Expand Down Expand Up @@ -650,7 +651,7 @@ export function App() {
</header>

<div className="flex flex-1 flex-col gap-5 p-3 sm:p-4 xl:p-5">
{workspaceMode === "board" && (
{showBoardHeaderActions && (
<InputGroup className="xl:hidden">
<InputGroupAddon>
<Search />
Expand Down
179 changes: 166 additions & 13 deletions src/client/components/SettingsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ afterEach(() => {

describe("SettingsView", () => {
it("saves focus area changes", async () => {
const onSave = vi.fn(async (_nextSettings: AppSettings) => undefined);
const onSave = vi.fn(
async (_nextSettings: AppSettings, _providerPatches: ProviderConfigPatch[]) => undefined,
);

await act(async () => {
root.render(
Expand All @@ -77,6 +79,8 @@ describe("SettingsView", () => {
);
});

await clickButtonByText("Board");

expect(container.textContent).toContain("Board settings");
expect(container.textContent).toContain("Focus areas");

Expand All @@ -101,9 +105,10 @@ describe("SettingsView", () => {
label: "Clients",
color: "blue",
});
expect(onSave.mock.calls[0][1]).toEqual([]);
});

it("labels settings select triggers for assistive technology", async () => {
it("labels settings controls for assistive technology", async () => {
await act(async () => {
root.render(
<SettingsView
Expand Down Expand Up @@ -165,11 +170,16 @@ describe("SettingsView", () => {
);
});

expect(container.querySelector('button[aria-label="Authentication"]')).toBeTruthy();
expect(container.querySelector('[aria-label="Authentication method"]')).toBeTruthy();
expect(container.textContent).toContain("OpenAI account");
expect(container.textContent).toContain("API key");
expect(container.querySelector('button[aria-label="Model"]')).toBeTruthy();
expect(container.querySelector('button[aria-label="Personal color"]')).toBeTruthy();
expect(container.textContent).toContain("Connected as ...abc123.");
expect(container.textContent).not.toContain("00000000-0000-0000");

await clickButtonByText("Board");

expect(container.querySelector('button[aria-label="Personal color"]')).toBeTruthy();
});

it("saves advanced OpenAI request settings", async () => {
Expand Down Expand Up @@ -208,28 +218,34 @@ describe("SettingsView", () => {
);
});

const maxTokensInput = container.querySelector<HTMLInputElement>("#openai-max-tokens");
const timeoutInput = container.querySelector<HTMLInputElement>("#openai-timeout");
const orgInput = container.querySelector<HTMLInputElement>("#openai-organization");
const projectInput = container.querySelector<HTMLInputElement>("#openai-project");
expect(maxTokensInput).toBeTruthy();
expect(timeoutInput).toBeTruthy();
expect(orgInput).toBeTruthy();
expect(projectInput).toBeTruthy();

await act(async () => {
setInputValue(maxTokensInput!, "8192");
maxTokensInput!.dispatchEvent(new Event("input", { bubbles: true }));
setInputValue(timeoutInput!, "90000");
timeoutInput!.dispatchEvent(new Event("input", { bubbles: true }));
setInputValue(orgInput!, "org_saved");
orgInput!.dispatchEvent(new Event("input", { bubbles: true }));
setInputValue(projectInput!, "proj_saved");
projectInput!.dispatchEvent(new Event("input", { bubbles: true }));
});

await clickButtonByText("Advanced");

const maxTokensInput = container.querySelector<HTMLInputElement>("#openai-max-tokens");
const timeoutInput = container.querySelector<HTMLInputElement>("#openai-timeout");
expect(maxTokensInput).toBeTruthy();
expect(timeoutInput).toBeTruthy();

await act(async () => {
setInputValue(maxTokensInput!, "8192");
maxTokensInput!.dispatchEvent(new Event("input", { bubbles: true }));
setInputValue(timeoutInput!, "90000");
timeoutInput!.dispatchEvent(new Event("input", { bubbles: true }));
});

const saveButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Save OpenAI settings"),
button.textContent?.includes("Save advanced settings"),
);
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
Expand All @@ -245,8 +261,145 @@ describe("SettingsView", () => {
projectId: "proj_saved",
});
});

it("blocks invalid advanced numeric values", async () => {
const onSave = vi.fn(
async (_nextSettings: AppSettings, _providerPatches: ProviderConfigPatch[]) => undefined,
);

await act(async () => {
root.render(
<SettingsView
settings={{
...settings,
providerConfigs: [
{
...settings.providerConfigs[0],
authMode: "api_key",
},
],
}}
providerStatuses={[]}
onSave={onSave}
onStartOpenAiAuth={vi.fn(async () => emptyAuthState)}
onGetOpenAiAuth={vi.fn(async () => emptyAuthState)}
onGetOpenAiAccountInfo={vi.fn(async () => ({
configured: false,
prefetchedAt: "2026-05-02T00:00:00.000Z",
recommendedModel: "gpt-5.5",
currentModel: "gpt-5.5",
currentModelSupported: true,
models: [],
}))}
onSubmitOpenAiAuthInput={vi.fn(async () => emptyAuthState)}
onLogoutOpenAiAuth={vi.fn(async () => emptyAuthState)}
/>,
);
});

await clickButtonByText("Advanced");

const temperatureInput = container.querySelector<HTMLInputElement>("#openai-temperature");
expect(temperatureInput).toBeTruthy();

await act(async () => {
setInputValue(temperatureInput!, "3");
temperatureInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await clickButtonByText("Save advanced settings");

expect(container.textContent).toContain("Temperature must be 2 or lower.");
expect(onSave).not.toHaveBeenCalled();
});

it("focuses a new focus area after adding it", async () => {
await act(async () => {
root.render(
<SettingsView
settings={settings}
providerStatuses={[]}
onSave={vi.fn(async () => undefined)}
onStartOpenAiAuth={vi.fn(async () => emptyAuthState)}
onGetOpenAiAuth={vi.fn(async () => emptyAuthState)}
onGetOpenAiAccountInfo={vi.fn(async () => ({
configured: false,
prefetchedAt: "2026-05-02T00:00:00.000Z",
recommendedModel: "gpt-5.5",
currentModel: "gpt-5.5",
currentModelSupported: true,
models: [],
}))}
onSubmitOpenAiAuthInput={vi.fn(async () => emptyAuthState)}
onLogoutOpenAiAuth={vi.fn(async () => emptyAuthState)}
/>,
);
});

await clickButtonByText("Board");
await clickButtonByText("Add area");

const newAreaInput = container.querySelector<HTMLInputElement>('input[aria-label="New area name"]');
expect(newAreaInput).toBeTruthy();
expect(document.activeElement).toBe(newAreaInput);
});

it("blocks empty focus area names", async () => {
const onSave = vi.fn(
async (_nextSettings: AppSettings, _providerPatches: ProviderConfigPatch[]) => undefined,
);

await act(async () => {
root.render(
<SettingsView
settings={settings}
providerStatuses={[]}
onSave={onSave}
onStartOpenAiAuth={vi.fn(async () => emptyAuthState)}
onGetOpenAiAuth={vi.fn(async () => emptyAuthState)}
onGetOpenAiAccountInfo={vi.fn(async () => ({
configured: false,
prefetchedAt: "2026-05-02T00:00:00.000Z",
recommendedModel: "gpt-5.5",
currentModel: "gpt-5.5",
currentModelSupported: true,
models: [],
}))}
onSubmitOpenAiAuthInput={vi.fn(async () => emptyAuthState)}
onLogoutOpenAiAuth={vi.fn(async () => emptyAuthState)}
/>,
);
});

await clickButtonByText("Board");

const nameInput = container.querySelector<HTMLInputElement>('input[aria-label="Personal name"]');
expect(nameInput).toBeTruthy();

await act(async () => {
setInputValue(nameInput!, "");
nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await clickButtonByText("Save board settings");

expect(container.textContent).toContain("Enter a focus area name.");
expect(container.querySelector<HTMLInputElement>('input[aria-label="Focus area name"]')).toBeTruthy();
expect(onSave).not.toHaveBeenCalled();
});
});

async function clickButtonByText(text: string) {
const button = Array.from(container.querySelectorAll("button")).find((entry) =>
entry.textContent?.includes(text),
);
expect(button).toBeTruthy();
await act(async () => {
button!.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, button: 0 }));
button!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
button!.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 }));
button!.click();
});
}

function setInputValue(input: HTMLInputElement, value: string): void {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
Expand Down
Loading