Skip to content
Draft
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
28 changes: 19 additions & 9 deletions src/board/boardConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,50 @@

export type ThemePreference = 'dark' | 'light' | 'system';
export const VALID_THEMES: ThemePreference[] = ['dark', 'light', 'system'];
export type LlmPreference = 'codex' | 'claude';
export const VALID_LLMS: LlmPreference[] = ['codex', 'claude'];

export interface BoardConfig {
detailPaneWidth?: number;
theme?: ThemePreference;
llm?: LlmPreference;
}

interface RawConfigFile {
board?: {
detailPaneWidth?: unknown;
theme?: unknown;
llm?: unknown;
[key: string]: unknown;
};
[key: string]: unknown;
}

/**
* Read board config from .agkan/config.yml (or the provided directory).
* Returns an empty object if the file does not exist, is invalid, or values are out of range.
* Defaults llm to "claude" when absent/invalid.
*/
export function readBoardConfig(configDir: string): BoardConfig {

Check warning on line 32 in src/board/boardConfig.ts

View workflow job for this annotation

GitHub Actions / quality-check

Function 'readBoardConfig' has a complexity of 13. Maximum allowed is 10

Check warning on line 32 in src/board/boardConfig.ts

View workflow job for this annotation

GitHub Actions / quality-check

Function 'readBoardConfig' has too many lines (42). Maximum allowed is 40
const configFile = path.join(configDir, 'config.yml');

if (!fs.existsSync(configFile)) {
return {};
if (fs.existsSync(configFile) === false) {
return { llm: 'claude' };
}

try {
const content = fs.readFileSync(configFile, 'utf8');
const raw = yaml.load(content) as RawConfigFile | null | undefined;

if (!raw || typeof raw !== 'object') {
return {};
if (raw == null || typeof raw !== 'object') {
return { llm: 'claude' };
}

const board = raw.board;
if (!board || typeof board !== 'object') {
return {};
if (board == null || typeof board !== 'object') {
return { llm: 'claude' };
}

const result: BoardConfig = {};
const result: BoardConfig = { llm: 'claude' };

const detailPaneWidth = board.detailPaneWidth;
if (typeof detailPaneWidth === 'number' && detailPaneWidth <= DETAIL_PANE_MAX_WIDTH) {
Expand All @@ -57,9 +61,14 @@
result.theme = theme as ThemePreference;
}

const llm = board.llm;
if (typeof llm === 'string' && (VALID_LLMS as string[]).includes(llm)) {
result.llm = llm as LlmPreference;
}

return result;
} catch {
return {};
return { llm: 'claude' };
}
}

Expand Down Expand Up @@ -92,6 +101,7 @@
...(existing.board ?? {}),
...(config.detailPaneWidth !== undefined ? { detailPaneWidth: config.detailPaneWidth } : {}),
...(config.theme !== undefined ? { theme: config.theme } : {}),
...(config.llm !== undefined ? { llm: config.llm } : {}),
},
};

Expand Down
17 changes: 16 additions & 1 deletion src/board/boardRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
</div>`;
}

function getPurgeAndVersionModals(): string {

Check warning on line 158 in src/board/boardRenderer.ts

View workflow job for this annotation

GitHub Actions / quality-check

Function 'getPurgeAndVersionModals' has too many lines (65). Maximum allowed is 40
return `
<div class="modal-overlay" id="purge-confirm-modal">
<div class="modal">
Expand Down Expand Up @@ -203,6 +203,21 @@
<button id="version-info-close">Close</button>
</div>
</div>
</div>
<div class="modal-overlay" id="settings-modal">
<div class="modal" style="width:360px;">
<h2>Settings</h2>
<label for="settings-llm-select">LLM</label>
<select id="settings-llm-select">
<option value="codex">codex</option>
<option value="claude" selected>claude</option>
</select>
<p id="settings-result" style="font-size:13px;min-height:18px;margin-bottom:8px;"></p>
<div class="modal-actions">
<button id="settings-cancel-btn">Cancel</button>
<button id="settings-save-btn" class="primary">Save</button>
</div>
</div>
</div>`;
}

Expand All @@ -218,7 +233,7 @@
<script src="/static/board.js"></script>`;
}

export function renderBoard(

Check warning on line 236 in src/board/boardRenderer.ts

View workflow job for this annotation

GitHub Actions / quality-check

Function 'renderBoard' has too many lines (54). Maximum allowed is 40
tasksByStatus: Map<TaskStatus, Task[]>,
tagMap: Map<number, Tag[]>,
boardTitle?: string,
Expand All @@ -243,7 +258,7 @@
</style>
</head>
<body>
<header><h1>agkan board<div id="header-running-indicator" style="display:none"></div></h1>${titleHtml}<div class="burger-menu-wrapper"><button class="burger-menu-btn" id="burger-menu-btn" title="Menu" aria-label="Menu"><span></span><span></span><span></span></button><div class="burger-menu-dropdown" id="burger-menu-dropdown"><div class="burger-menu-item danger" id="burger-purge-tasks">&#128465; Purge Tasks</div><div class="burger-menu-item" id="burger-archive-tasks">&#128230; Archive Tasks</div><div class="burger-menu-item" id="burger-export-tasks">&#8595; Export Tasks</div><div class="burger-menu-item" id="burger-import-tasks">&#8593; Import Tasks</div><div class="burger-menu-item" id="burger-version-info">&#8505; Version Info</div><div class="burger-menu-separator"></div><div class="burger-menu-item" id="burger-theme-dark">Dark Mode</div><div class="burger-menu-item" id="burger-theme-light">Light Mode</div><div class="burger-menu-item" id="burger-theme-system">System Setting</div></div></div></header>
<header><h1>agkan board<div id="header-running-indicator" style="display:none"></div></h1>${titleHtml}<div class="burger-menu-wrapper"><button class="burger-menu-btn" id="burger-menu-btn" title="Menu" aria-label="Menu"><span></span><span></span><span></span></button><div class="burger-menu-dropdown" id="burger-menu-dropdown"><div class="burger-menu-item danger" id="burger-purge-tasks">&#128465; Purge Tasks</div><div class="burger-menu-item" id="burger-archive-tasks">&#128230; Archive Tasks</div><div class="burger-menu-item" id="burger-export-tasks">&#8595; Export Tasks</div><div class="burger-menu-item" id="burger-import-tasks">&#8593; Import Tasks</div><div class="burger-menu-item" id="burger-settings">&#9881; Settings</div><div class="burger-menu-item" id="burger-version-info">&#8505; Version Info</div><div class="burger-menu-separator"></div><div class="burger-menu-item" id="burger-theme-dark">Dark Mode</div><div class="burger-menu-item" id="burger-theme-light">Light Mode</div><div class="burger-menu-item" id="burger-theme-system">System Setting</div></div></div></header>
<div class="filter-bar" id="filter-bar">
<div class="filter-group">
<input type="search" id="filter-search" class="filter-search-input" placeholder="Search tasks...">
Expand Down
44 changes: 30 additions & 14 deletions src/board/boardRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@
import { CommentService } from '../services/CommentService';
import { TaskBlockService } from '../services/TaskBlockService';
import { ExportImportService, ExportData } from '../services/ExportImportService';
import { ClaudeProcessService } from '../services/ClaudeProcessService';
import type { IProcessService } from '../services/IProcessService';
import { TaskStatus, isPriority, Priority } from '../models';
import { ConflictError } from '../errors';
import { StorageBackend } from '../db/types/repository';
import { readBoardConfig, writeBoardConfig, DETAIL_PANE_MAX_WIDTH, VALID_THEMES, ThemePreference } from './boardConfig';
import {
readBoardConfig,
writeBoardConfig,
DETAIL_PANE_MAX_WIDTH,
VALID_THEMES,
ThemePreference,
VALID_LLMS,
LlmPreference,
} from './boardConfig';
import { daysAgoIso } from '../utils/date';
import {
buildTasksByStatus,
Expand All @@ -34,7 +42,7 @@
database: StorageBackend;
boardTitle?: string;
configDir: string;
claudeProcessService?: ClaudeProcessService;
processService?: IProcessService;
};

type TaskPatchBody = {
Expand Down Expand Up @@ -76,7 +84,7 @@
return { input };
}

function registerTaskCrudRoutes(

Check warning on line 87 in src/board/boardRoutes.ts

View workflow job for this annotation

GitHub Actions / quality-check

Function 'registerTaskCrudRoutes' has too many lines (93). Maximum allowed is 40
app: Hono,
ts: TaskService,
tts: TaskTagService,
Expand All @@ -91,7 +99,7 @@
}
return c.json({ tasks: ts.listTasks({}, 'id', 'asc') });
});
app.post('/api/tasks', async (c) => {

Check warning on line 102 in src/board/boardRoutes.ts

View workflow job for this annotation

GitHub Actions / quality-check

Async arrow function has a complexity of 19. Maximum allowed is 10

Check warning on line 102 in src/board/boardRoutes.ts

View workflow job for this annotation

GitHub Actions / quality-check

Async arrow function has too many lines (49). Maximum allowed is 40
const body = await c.req.json<{
title: string;
body?: string | null;
Expand Down Expand Up @@ -170,7 +178,7 @@
});
}

function registerCommentRoutes(app: Hono, cs: CommentService, ts: TaskService): void {

Check warning on line 181 in src/board/boardRoutes.ts

View workflow job for this annotation

GitHub Actions / quality-check

Function 'registerCommentRoutes' has too many lines (52). Maximum allowed is 40
app.get('/api/tasks/:id/comments', (c) => {
const id = Number(c.req.param('id'));
if (isNaN(id)) return c.json({ error: 'Invalid task id' }, 400);
Expand Down Expand Up @@ -262,7 +270,7 @@
});
}

function registerUtilityRoutes(app: Hono, ts: TaskService): void {

Check warning on line 273 in src/board/boardRoutes.ts

View workflow job for this annotation

GitHub Actions / quality-check

Function 'registerUtilityRoutes' has too many lines (63). Maximum allowed is 40
app.post('/api/tasks/purge', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { beforeDate?: string };
let beforeDate: string;
Expand Down Expand Up @@ -372,8 +380,8 @@
return c.json({ board: boardConfig });
});

app.put('/api/config', async (c) => {

Check warning on line 383 in src/board/boardRoutes.ts

View workflow job for this annotation

GitHub Actions / quality-check

Async arrow function has a complexity of 13. Maximum allowed is 10
const body = await c.req.json<{ board?: { detailPaneWidth?: unknown; theme?: unknown } }>();
const body = await c.req.json<{ board?: { detailPaneWidth?: unknown; theme?: unknown; llm?: unknown } }>();
const boardBody = body?.board ?? {};

if (boardBody.detailPaneWidth !== undefined) {
Expand All @@ -395,6 +403,14 @@
writeBoardConfig(configDir, { theme: theme as ThemePreference });
}

if (boardBody.llm !== undefined) {
const llm = boardBody.llm;
if (typeof llm !== 'string' || !(VALID_LLMS as string[]).includes(llm)) {
return c.json({ error: 'llm must be one of: ' + VALID_LLMS.join(', ') }, 400);
}
writeBoardConfig(configDir, { llm: llm as LlmPreference });
}

return c.json({ success: true });
});
}
Expand Down Expand Up @@ -437,7 +453,7 @@
return filters;
}

function registerClaudeRoutes(app: Hono, claudeProcess: ClaudeProcessService, ts: TaskService): void {
function registerProcessRoutes(app: Hono, processService: IProcessService, ts: TaskService): void {
app.post('/api/claude/tasks/:taskId/run', async (c) => {
const taskId = Number(c.req.param('taskId'));
if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400);
Expand All @@ -453,11 +469,11 @@
: `Task ID: ${taskId}\n/agkan-subtask-direct`;

try {
claudeProcess.startProcess(taskId, prompt, command);
processService.startProcess(taskId, prompt, command);
} catch (e) {
if (e instanceof ConflictError) {
console.error(
`[boardRoutes] 409 already running taskId=${taskId} command=${command} running=${JSON.stringify(claudeProcess.listRunningTasks())}`
`[boardRoutes] 409 already running taskId=${taskId} command=${command} running=${JSON.stringify(processService.listRunningTasks())}`
);
return c.json({ error: e.message }, 409);
}
Expand All @@ -466,7 +482,7 @@

if (command === 'pr' || command === 'run') {
const targetStatus = command === 'pr' ? 'review' : 'done';
const unsubscribe = claudeProcess.subscribeOutput(taskId, (evt) => {
const unsubscribe = processService.subscribeOutput(taskId, (evt) => {
if (evt.kind === 'done' && evt.exitCode === 0) {
ts.updateTask(taskId, { status: targetStatus });
}
Expand All @@ -482,7 +498,7 @@
app.delete('/api/claude/tasks/:taskId/run', (c) => {
const taskId = Number(c.req.param('taskId'));
if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400);
const stopped = claudeProcess.stopProcess(taskId);
const stopped = processService.stopProcess(taskId);
if (!stopped) return c.json({ error: 'No running process for this taskId' }, 404);
return c.json({ success: true });
});
Expand All @@ -500,7 +516,7 @@
return new TextEncoder().encode(payload);
};

const stop = claudeProcess.subscribeOutput(taskId, (evt) => {
const stop = processService.subscribeOutput(taskId, (evt) => {
if (evt.kind === 'text') {
controller.enqueue(encode('text', { text: evt.text }));
} else if (evt.kind === 'tool_use') {
Expand Down Expand Up @@ -531,15 +547,15 @@
});

app.get('/api/running-tasks', (c) => {
const tasks = claudeProcess.listRunningTasks();
const tasks = processService.listRunningTasks();
return c.json({ tasks });
});

app.get('/api/claude/tasks/:taskId/run-logs', (c) => {
const taskId = Number(c.req.param('taskId'));
if (isNaN(taskId)) return c.json({ error: 'Invalid taskId' }, 400);
if (!ts.getTask(taskId)) return c.json({ error: 'Task not found' }, 404);
const logs = claudeProcess.getRunLogs(taskId);
const logs = processService.getRunLogs(taskId);
return c.json({ logs });
});
}
Expand Down Expand Up @@ -592,7 +608,7 @@
});
registerTaskApiRoutes(app, services);
registerConfigApiRoutes(app, configDir);
if (services.claudeProcessService) {
registerClaudeRoutes(app, services.claudeProcessService, ts);
if (services.processService) {
registerProcessRoutes(app, services.processService, ts);
}
}
75 changes: 74 additions & 1 deletion src/board/client/burgerMenu.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
// Burger menu, purge tasks, version info, and dark mode functionality
// Burger menu, purge tasks, version info, settings, and dark mode functionality

import { initDarkMode } from './darkMode';
import { refreshBoardCards } from './boardPolling';

type LlmPreference = 'codex' | 'claude';

function normalizeLlm(value: unknown): LlmPreference {
return value === 'codex' ? 'codex' : 'claude';
}

function initBurgerToggle(burgerBtn: HTMLButtonElement, burgerDropdown: HTMLElement): void {
burgerBtn.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation();
Expand Down Expand Up @@ -133,6 +139,72 @@ function initVersionModal(burgerDropdown: HTMLElement): void {
});
}

function initSettingsModal(burgerDropdown: HTMLElement): void {
const settingsModal = document.getElementById('settings-modal') as HTMLElement | null;
const settingsSelect = document.getElementById('settings-llm-select') as HTMLSelectElement | null;
const settingsResultEl = document.getElementById('settings-result') as HTMLElement | null;
const settingsCancelBtn = document.getElementById('settings-cancel-btn') as HTMLButtonElement | null;
const settingsSaveBtn = document.getElementById('settings-save-btn') as HTMLButtonElement | null;

if (!settingsModal || !settingsSelect || !settingsResultEl || !settingsCancelBtn || !settingsSaveBtn) {
return;
}

document.getElementById('burger-settings')?.addEventListener('click', async () => {
burgerDropdown.classList.remove('open');
settingsResultEl.textContent = '';
settingsResultEl.style.color = '#64748b';
settingsSelect.value = 'claude';
settingsModal.classList.add('show');

try {
const res = await fetch('/api/config');
if (res.ok) {
const data = (await res.json()) as { board?: { llm?: unknown } };
settingsSelect.value = normalizeLlm(data?.board?.llm);
}
} catch {
// Ignore load errors and keep default
}
});

settingsCancelBtn.addEventListener('click', () => {
settingsModal.classList.remove('show');
});

settingsSaveBtn.addEventListener('click', async () => {
const selected = normalizeLlm(settingsSelect.value);
settingsSaveBtn.disabled = true;
settingsSaveBtn.textContent = 'Saving...';

try {
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ board: { llm: selected } }),
});

if (res.ok) {
settingsResultEl.textContent = 'Saved.';
settingsResultEl.style.color = '#16a34a';
setTimeout(() => {
settingsModal.classList.remove('show');
}, 250);
} else {
const data = (await res.json().catch(() => ({}))) as { error?: string };
settingsResultEl.textContent = data.error || 'Failed to save settings.';
settingsResultEl.style.color = '#dc2626';
}
} catch {
settingsResultEl.textContent = 'Failed to save settings.';
settingsResultEl.style.color = '#dc2626';
} finally {
settingsSaveBtn.disabled = false;
settingsSaveBtn.textContent = 'Save';
}
});
}

function initExportModal(burgerDropdown: HTMLElement): void {
document.getElementById('burger-export-tasks')?.addEventListener('click', () => {
burgerDropdown.classList.remove('open');
Expand Down Expand Up @@ -250,6 +322,7 @@ export function initBurgerMenu(): void {
initArchiveModal(burgerDropdown);
initExportModal(burgerDropdown);
initImportModal(burgerDropdown);
initSettingsModal(burgerDropdown);
initVersionModal(burgerDropdown);
initDarkMode();
}
Loading
Loading