diff --git a/.gitignore b/.gitignore index b595414..793d266 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ logs/ .claude/ .playwright-mcp/ .superpowers/ +# Agent skill installs (modern-web-guidance etc. via `skills add`) — local tooling, +# not project source. +.agents/ +skills-lock.json orch.pid .server.pid src/generated/ diff --git a/src/config-manager.test.ts b/src/config-manager.test.ts index 6ddfd9d..183a5d4 100644 --- a/src/config-manager.test.ts +++ b/src/config-manager.test.ts @@ -1,6 +1,6 @@ // src/config-manager.test.ts import { describe, it, expect, beforeEach } from 'vitest'; -import { mkdtempSync, writeFileSync, readFileSync } from 'fs'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { ConfigManager } from './config-manager.js'; @@ -85,6 +85,36 @@ describe('ConfigManager', () => { expect(cm.getConfig().provider.workers[0]?.model).toBe('new-model'); }); + it('creates config.yaml on first save when the file does not exist (fresh install on defaults)', () => { + // Fresh OSS deploy: the server boots on defaults with no config.yaml on + // disk. The first Settings-UI save must CREATE the file, not crash with + // ENOENT trying to back up a non-existent file. + const freshPath = join(tempDir, 'fresh-config.yaml'); + expect(existsSync(freshPath)).toBe(false); + + const cm = new ConfigManager(freshPath); + const result = cm.updateConfig({ + llm: { + workers: [{ + id: 'w1', + connectionType: 'direct', + endpoint: 'http://host:11434/v1', + model: 'qwen3:8b', + roles: ['auto', 'fast'], + maxConcurrency: 1, + enabled: true, + }], + }, + }); + + expect(result.ok).toBe(true); + expect(existsSync(freshPath)).toBe(true); + const raw = readFileSync(freshPath, 'utf-8'); + expect(raw).toContain('config_version: 2'); + expect(raw).toContain('http://host:11434/v1'); + expect(cm.getConfig().provider.workers[0]?.model).toBe('qwen3:8b'); + }); + it('rejects invalid config (unparseable YAML file)', () => { const cm = new ConfigManager(configPath); // Corrupt the file, then try to reload — loadConfig will fall back to defaults diff --git a/src/config-manager.ts b/src/config-manager.ts index 9cdd135..54f5ded 100644 --- a/src/config-manager.ts +++ b/src/config-manager.ts @@ -1,6 +1,6 @@ // src/config-manager.ts import { EventEmitter } from 'events'; -import { readFileSync, writeFileSync, statSync } from 'fs'; +import { readFileSync, writeFileSync, statSync, existsSync, unlinkSync } from 'fs'; import { stringify } from 'yaml'; import { loadConfig, toSnakeKeys, type AppConfig } from './config.js'; import { createHash } from 'crypto'; @@ -155,15 +155,25 @@ export class ConfigManager { const snakeConfig = toSnakeKeys(merged) as Record; const yamlStr = stringify(snakeConfig, { lineWidth: 120 }); - // Validate BEFORE writing: backup, write, validate, rollback on failure - const backupContent = readFileSync(this.configPath, 'utf-8'); + // Validate BEFORE writing: backup, write, validate, rollback on failure. + // config.yaml may not exist yet — a fresh install boots on defaults with no + // file on disk, and the first save must CREATE it rather than ENOENT trying + // to back up a missing file. With no prior content, a failed save is rolled + // back by removing the file we just created. + const backupContent = existsSync(this.configPath) + ? readFileSync(this.configPath, 'utf-8') + : null; try { writeFileSync(this.configPath, yamlStr, 'utf-8'); logger.info(`[config-manager] config written to ${this.configPath}`); this.currentConfig = loadConfig(this.configPath); } catch (e) { - // Restore original file on validation failure - writeFileSync(this.configPath, backupContent, 'utf-8'); + // Restore original on validation failure (or drop the file we created). + if (backupContent !== null) { + writeFileSync(this.configPath, backupContent, 'utf-8'); + } else { + try { unlinkSync(this.configPath); } catch { /* nothing to revert */ } + } logger.warn(`[config-manager] config update failed, reverted: ${e}`); return { ok: false, errors: e, message: 'Invalid config — changes reverted' }; } diff --git a/ui/index.html b/ui/index.html index 91b976a..a73135a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,6 +3,21 @@ + + @@ -23,10 +38,13 @@ min-height: 100dvh; } + html { background: #ffffff; } + html[data-theme="dark"] { background: #0a0a0c; } + body { margin: 0; overflow-x: hidden; - background: #f3f6fb; + background: transparent; } input, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8b33201..770ea7e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -411,7 +411,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
{!panelOpen ? (
-
+
} activeWidgetSlug={dashboardWidget} @@ -421,7 +421,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
) : ( setUrlState(prev => ({ ...prev, mobileTab: id }))} onSwipeRightFromEdge={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))} visibleTabs={mobileVisibleTabIds}> -
+
{mobileVisibleTabs.map(({ id, label }) => (
) : streamingText ? ( -
+
{streamingText}
@@ -366,7 +366,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C {!isAtBottom && (
{/* Composer */} -
+
{isBusy && (
void handleSubmit()} disabled={submitting} - className="flex-shrink-0 px-2 h-6 bg-white border border-red-200 rounded text-[10px] font-medium text-red-700 hover:bg-red-100 disabled:opacity-50" + className="flex-shrink-0 px-2 h-6 bg-canvas border border-red-200 rounded text-[10px] font-medium text-red-700 hover:bg-red-100 disabled:opacity-50" > 再送信 @@ -461,7 +461,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C diff --git a/ui/src/components/dashboard/SideInfoPanel.tsx b/ui/src/components/dashboard/SideInfoPanel.tsx index e7e4748..f41b05c 100644 --- a/ui/src/components/dashboard/SideInfoPanel.tsx +++ b/ui/src/components/dashboard/SideInfoPanel.tsx @@ -30,7 +30,7 @@ export function SideInfoPanel({ const activeWidget = widgets.find(w => w.slug === activeSlug); return ( -
+
+
{label} diff --git a/ui/src/components/detail/ContinueWithPieceDialog.tsx b/ui/src/components/detail/ContinueWithPieceDialog.tsx index 4487e39..cb04544 100644 --- a/ui/src/components/detail/ContinueWithPieceDialog.tsx +++ b/ui/src/components/detail/ContinueWithPieceDialog.tsx @@ -67,7 +67,7 @@ export function ContinueWithPieceDialog({ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={e => { if (e.target === e.currentTarget) onClose(); }} > -
+
{/* Header */}
diff --git a/ui/src/components/detail/DetailHeader.tsx b/ui/src/components/detail/DetailHeader.tsx index 3816e35..76f6c05 100644 --- a/ui/src/components/detail/DetailHeader.tsx +++ b/ui/src/components/detail/DetailHeader.tsx @@ -70,7 +70,7 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh disabled={shareMutation.isPending} title={shareMutation.isPending ? '共有中...' : '公開リンクを発行'} aria-label="公開リンクを発行" - className={`${iconBtnBase} border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface`} + className={`${iconBtnBase} border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface`} > {shareMutation.isPending ? ( @@ -102,7 +102,7 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh onClick={handleCopy} title={copied ? 'コピーしました' : '共有リンクをコピー'} aria-label="共有リンクをコピー" - className={`${iconBtnBase} ${copied ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface'}`} + className={`${iconBtnBase} ${copied ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface'}`} > {copied ? ( @@ -120,7 +120,7 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh disabled={unshareMutation.isPending} title="共有を停止" aria-label="共有を停止" - className={`${iconBtnBase} border-hairline bg-white text-slate-500 hover:text-red-700 hover:border-red-200 hover:bg-red-50`} + className={`${iconBtnBase} border-hairline bg-canvas text-slate-500 hover:text-red-700 hover:border-red-200 hover:bg-red-50`} > {unshareMutation.isPending ? ( @@ -150,7 +150,7 @@ function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string disabled={!enabled} title={enabled ? '別 piece で続ける' : 'タスクが進行中のため続行できません'} aria-label="別 piece で続ける" - className={`${iconBtnBase} border-hairline bg-white text-slate-600 hover:text-slate-900 hover:bg-surface`} + className={`${iconBtnBase} border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface`} > {/* arrow → divider: 「次のフェーズへ進む」cue。FileBrowser の refresh (循環矢印) と区別するためフラットな skip-forward 形状を採用 */} @@ -168,7 +168,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe // renders its own mobile-level top tab bar with the same controls. // Two close buttons / two tab bars on iPhone was visually redundant. return ( -
+
{subtitle}
diff --git a/ui/src/components/detail/DetailPanel.tsx b/ui/src/components/detail/DetailPanel.tsx index 17f7020..c3a36e1 100644 --- a/ui/src/components/detail/DetailPanel.tsx +++ b/ui/src/components/detail/DetailPanel.tsx @@ -195,7 +195,7 @@ export function LocalDetailPanel({ )}
{editingVisibility && ( -
+