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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ BOARD_DB_PATH=./data/board.db
# Optional API-key fallback. OpenAI account auth can be connected in Settings.
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_ORG_ID=
OPENAI_PROJECT_ID=
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ API_PORT=3001
BOARD_DB_PATH=./data/board.db
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_ORG_ID=
OPENAI_PROJECT_ID=
```

`WEB_PORT` controls the Vite dev and preview server. `API_PORT` controls the
Expand All @@ -108,6 +110,13 @@ Open Settings in the app to choose an auth mode:
- API-key mode: save a key locally or provide `OPENAI_API_KEY` through the
environment.

The OpenAI settings screen also supports advanced request controls for model
behavior and transport: max output tokens, temperature, reasoning effort,
reasoning summaries, text verbosity, request timeout, retries, retry delay,
prompt cache retention, and Codex transport. In API-key mode you can also set
OpenAI organization/project headers in the app or through `OPENAI_ORG_ID` and
`OPENAI_PROJECT_ID`.

API keys, OAuth tokens, SQLite databases, build output, and dependency folders
should not be committed.

Expand Down
10 changes: 0 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,10 @@
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5"
},
"overrides": {
"express-rate-limit": {
"ip-address": "10.2.0"
}
}
}
35 changes: 33 additions & 2 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,28 @@ export function App() {
return;
}
const refreshed = tasks.find((task) => task.id === editingTask.id);
let cancelled = false;
if (
refreshed &&
(refreshed.updatedAt !== editingTask.updatedAt ||
refreshed.execution?.updatedAt !== editingTask.execution?.updatedAt)
) {
setEditingTask(refreshed);
void api
.getTask(editingTask.id)
.then((response) => {
if (!cancelled) {
setEditingTask(response.task);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
});
}
return () => {
cancelled = true;
};
}, [editingTask, tasks]);

const filteredTasks = useMemo(() => {
Expand Down Expand Up @@ -211,6 +226,21 @@ export function App() {
setEditingTask((current) => (current?.id === taskId ? result.task : current));
}

async function createDraftTask(input: TaskCreateInput) {
const result = await api.createTask(input);
setTasks((current) => [...current, result.task]);
}

function openTaskEditor(task: Task) {
setEditingTask(task);
void api
.getTask(task.id)
.then((response) => {
setEditingTask((current) => (current?.id === task.id ? response.task : current));
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}

async function deleteTask(id: string) {
await api.deleteTask(id);
setTasks((current) => current.filter((task) => task.id !== id));
Expand Down Expand Up @@ -507,7 +537,7 @@ export function App() {
tasks={filteredTasks}
focusAreas={focusAreas}
onCreateTask={setCreatingStatus}
onEditTask={setEditingTask}
onEditTask={openTaskEditor}
onMoveTask={moveTask}
/>
)}
Expand All @@ -528,6 +558,7 @@ export function App() {
}}
onDelete={editingTask ? () => deleteTask(editingTask.id) : undefined}
onAskFollowUp={editingTask ? (prompt) => askTaskFollowUp(editingTask.id, prompt) : undefined}
onCreateDraft={editingTask ? createDraftTask : undefined}
onSave={createOrUpdateTask}
/>
</Suspense>
Expand Down
3 changes: 3 additions & 0 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export const api = {
async listTasks() {
return request<{ tasks: Task[] }>("/api/tasks");
},
async getTask(id: string) {
return request<{ task: Task }>(`/api/tasks/${id}`);
},
async createTask(input: TaskCreateInput) {
return request<TaskMutationResponse>("/api/tasks", {
method: "POST",
Expand Down
54 changes: 50 additions & 4 deletions src/client/components/BoardView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Priority, Task } from "../../shared/types";
import type { Priority, Task, TaskExecution } from "../../shared/types";
import { BoardView } from "./BoardView";

(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
Expand Down Expand Up @@ -55,19 +55,65 @@ describe("BoardView", () => {
"Low priority task",
]);
});

it("shows running task execution state on the task card", async () => {
await act(async () => {
root.render(
<BoardView
tasks={[task("running-task", "Running task", "medium", execution())]}
focusAreas={[]}
singleColumn="in_progress"
onCreateTask={vi.fn()}
onEditTask={vi.fn()}
onMoveTask={vi.fn()}
/>,
);
});

const card = container.querySelector<HTMLButtonElement>(
'button[aria-label="Open task Running task"]',
);

expect(card?.textContent).toContain("Running");
});
});

function task(id: string, title: string, priority: Priority): Task {
function task(
id: string,
title: string,
priority: Priority,
execution: TaskExecution | null = null,
): Task {
return {
id,
title,
description: "",
status: "ready",
status: execution?.status === "running" ? "in_progress" : "ready",
priority,
focusAreaId: null,
tags: [],
providerSource: "local",
execution: null,
execution,
createdAt: "2026-05-02T00:00:00.000Z",
updatedAt: "2026-05-02T00:00:00.000Z",
};
}

function execution(): TaskExecution {
return {
id: "execution-1",
taskId: "running-task",
agentRunId: null,
status: "running",
provider: "openai",
model: "gpt-5.5",
startedAt: "2026-05-02T00:00:00.000Z",
endedAt: null,
progressSummary: "Preparing the task context.",
output: "",
error: null,
artifacts: [],
events: [],
createdAt: "2026-05-02T00:00:00.000Z",
updatedAt: "2026-05-02T00:00:00.000Z",
};
Expand Down
18 changes: 12 additions & 6 deletions src/client/components/MarkdownMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const markdownComponents: Components = {
<h3 className="text-sm font-medium text-foreground" {...props} />
),
p: ({ node: _node, ...props }) => (
<p className="text-sm leading-relaxed text-muted-foreground" {...props} />
<p className="break-words text-sm leading-relaxed text-muted-foreground" {...props} />
),
ul: ({ node: _node, ...props }) => (
<ul className="flex list-disc flex-col gap-1 pl-5 text-sm text-muted-foreground" {...props} />
Expand All @@ -26,7 +26,7 @@ const markdownComponents: Components = {
),
a: ({ node: _node, ...props }) => (
<a
className="font-medium text-primary underline underline-offset-4"
className="break-all font-medium text-primary underline underline-offset-4"
{...props}
target="_blank"
rel="noreferrer"
Expand All @@ -39,13 +39,19 @@ const markdownComponents: Components = {
/>
),
pre: ({ node: _node, ...props }) => (
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs text-foreground" {...props} />
<pre
className="max-w-full overflow-x-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs text-foreground"
{...props}
/>
),
code: ({ node: _node, ...props }) => (
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground" {...props} />
<code
className="break-all rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground"
{...props}
/>
),
table: ({ node: _node, ...props }) => (
<div className="overflow-x-auto">
<div className="max-w-full overflow-x-auto">
<table className="w-full text-sm text-muted-foreground" {...props} />
</div>
),
Expand All @@ -57,7 +63,7 @@ const markdownComponents: Components = {

export function MarkdownMessage(props: { children: string }) {
return (
<div className="flex flex-col gap-2">
<div className="flex min-w-0 flex-col gap-2">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{props.children}
</ReactMarkdown>
Expand Down
76 changes: 75 additions & 1 deletion src/client/components/SettingsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AppSettings, OpenAiAuthState } from "../../shared/types";
import type { AppSettings, OpenAiAuthState, ProviderConfigPatch } from "../../shared/types";
import { SettingsView } from "./SettingsView";

(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
Expand Down Expand Up @@ -171,6 +171,80 @@ describe("SettingsView", () => {
expect(container.textContent).toContain("Connected as ...abc123.");
expect(container.textContent).not.toContain("00000000-0000-0000");
});

it("saves advanced OpenAI request settings", 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",
organizationId: "org_existing",
},
],
}}
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)}
/>,
);
});

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 }));
});

const saveButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Save OpenAI settings"),
);
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave.mock.calls[0][1][0]).toMatchObject({
provider: "openai",
authMode: "api_key",
maxTokens: 8192,
timeoutMs: 90000,
organizationId: "org_saved",
projectId: "proj_saved",
});
});
});

function setInputValue(input: HTMLInputElement, value: string): void {
Expand Down
Loading